From 0e2d976f9a57bee373ce99af5ca16dcebc35ee3a Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 16 May 2026 10:49:10 +0300 Subject: [PATCH] feat(parcels): layout signature schemas + extractor (#113 PR A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2.1 minimal foundation for #113 Layout Analysis (Макс feedback 12.05). Без layout_type/balcony_count (отсутствуют в БД, ждут B2B Объектив #52). Added: - 7 Pydantic schemas в schemas/parcel.py: LayoutSignature, BestLayoutsRequest, TopLayoutRow, LayoutTzMixRow, LayoutTzRecommendation, LayoutDataQuality, BestLayoutsResponse - services/site_finder/layout_signature.py: room_bucket_from_flat / area_bin / layout_signature (3 pure-Python helpers, no DB) - tests/test_layout_signature.py: 27 unit tests (parametrized) No DB / API changes — foundation для PR B (mv_layout_velocity) и PR C (POST /best-layouts endpoint). --- backend/app/schemas/parcel.py | 81 ++++++++++++++++ .../services/site_finder/layout_signature.py | 58 +++++++++++ backend/tests/test_layout_signature.py | 97 +++++++++++++++++++ 3 files changed, 236 insertions(+) create mode 100644 backend/app/services/site_finder/layout_signature.py create mode 100644 backend/tests/test_layout_signature.py diff --git a/backend/app/schemas/parcel.py b/backend/app/schemas/parcel.py index c4e7e3ae..0a948e67 100644 --- a/backend/app/schemas/parcel.py +++ b/backend/app/schemas/parcel.py @@ -1,3 +1,4 @@ +import datetime as dt from typing import Any, Literal from pydantic import BaseModel, Field @@ -132,3 +133,83 @@ class CompetitorsSummary(BaseModel): class CompetitorsResponse(BaseModel): competitors: list[Competitor] summary: CompetitorsSummary + + +# ── Layout analysis (Issue #113) ─────────────────────────────────────────── + + +class LayoutSignature(BaseModel): + """Минимальная сигнатура планировки = (room_bucket, area_bin). + + Phase 2.1: layout_type/balcony_count в БД нет, ждут B2B Объектив (#52). + """ + + room_bucket: Literal["studio", "1", "2", "3", "4+"] + area_bin: Literal["<25", "25-40", "40-60", "60-80", "80-100", "100+"] + + +class BestLayoutsRequest(BaseModel): + """Параметры запроса top-планировок в радиусе вокруг участка.""" + + radius_km: float = Field(default=1.0, ge=0.1, le=1.5) + time_window: Literal["last_month", "last_quarter", "last_year"] = "last_quarter" + filter_competitor_obj_ids: list[int] | None = None + exclude_competitor_obj_ids: list[int] = Field(default_factory=list) + min_velocity_per_month: float = Field(default=0.5, ge=0.0, le=100.0) + obj_class_filter: Literal["economy", "comfort", "business"] | None = None + target_total_flats: int | None = Field(default=None, ge=1, le=10000) + + +class TopLayoutRow(BaseModel): + """Одна строка top-planirovok ranking'а.""" + + rank: int + room_bucket: str + area_bin: str + signature: str + competitor_obj_ids: list[int] + competitor_count: int + total_sold_in_window: int + velocity_per_month: float + avg_price_per_m2_rub: float | None # NULL если objective не покрывает obj + avg_area_m2: float + supply_units_in_radius: int + sold_pct_of_supply: float | None # NULL если supply=0 + + +class LayoutTzMixRow(BaseModel): + """Строка рекомендации unit-mix для ТЗ.""" + + room_bucket: str + pct: int # 0..100, sum total = 100 + abs_units: int | None # NULL если target_total_flats не задан + avg_target_area_m2: float | None + + +class LayoutTzRecommendation(BaseModel): + """Рекомендация для ТЗ на проектирование.""" + + rationale_text: str + mix: list[LayoutTzMixRow] + weighted_avg_price_per_m2_rub: float | None + based_on_obj_count: int + based_on_total_deals: int + data_window_start: dt.date + data_window_end: dt.date + + +class LayoutDataQuality(BaseModel): + """Метаданные качества данных (coverage).""" + + objects_with_velocity_data: int + objects_total_in_radius: int + velocity_coverage_pct: float + confidence: Literal["high", "medium", "low"] + + +class BestLayoutsResponse(BaseModel): + """Ответ /best-layouts endpoint'а.""" + + top_layouts: list[TopLayoutRow] + recommendation_for_tz: LayoutTzRecommendation + data_quality: LayoutDataQuality diff --git a/backend/app/services/site_finder/layout_signature.py b/backend/app/services/site_finder/layout_signature.py new file mode 100644 index 00000000..9fa628dd --- /dev/null +++ b/backend/app/services/site_finder/layout_signature.py @@ -0,0 +1,58 @@ +"""Layout signature extraction для Issue #113 (Phase 2.1 minimal). + +Без `layout_type` / `balcony_count` — ждут B2B Объектив (#52). +""" + +from __future__ import annotations + +from typing import Literal + +RoomBucket = Literal["studio", "1", "2", "3", "4+"] +AreaBin = Literal["<25", "25-40", "40-60", "60-80", "80-100", "100+"] + + +def room_bucket_from_flat( + rooms: int | None, + flat_type: str | None, + is_studio: bool | None, +) -> RoomBucket: + """Determine room_bucket из kn_flats полей. + + Priority: + 1. is_studio=True OR flat_type='Квартира-студия' → "studio" + 2. rooms IN (1, 2, 3) → str(rooms) + 3. rooms >= 4 → "4+" + 4. rooms = 0 (без is_studio) → "studio" + 5. fallback на "1" (rooms is None и не studio) + """ + if is_studio is True or flat_type == "Квартира-студия": + return "studio" + if rooms is None: + return "1" + if rooms == 0: + return "studio" + if rooms >= 4: + return "4+" + if rooms in (1, 2, 3): + return str(rooms) # type: ignore[return-value] + return "1" + + +def area_bin(area_m2: float) -> AreaBin: + """Bucket площади per ARN-buckets (НСПД).""" + if area_m2 < 25.0: + return "<25" + if area_m2 < 40.0: + return "25-40" + if area_m2 < 60.0: + return "40-60" + if area_m2 < 80.0: + return "60-80" + if area_m2 < 100.0: + return "80-100" + return "100+" + + +def layout_signature(room_bucket_val: RoomBucket, area_bin_val: AreaBin) -> str: + """Deterministic string-signature для группировки.""" + return f"{room_bucket_val}__{area_bin_val}" diff --git a/backend/tests/test_layout_signature.py b/backend/tests/test_layout_signature.py new file mode 100644 index 00000000..1fcad752 --- /dev/null +++ b/backend/tests/test_layout_signature.py @@ -0,0 +1,97 @@ +"""Тесты для layout_signature helpers (Issue #113, Phase 2.1). + +Pure-Python, без БД и external calls. +""" + +from __future__ import annotations + +import pytest + +from app.services.site_finder.layout_signature import ( + area_bin, + layout_signature, + room_bucket_from_flat, +) + + +@pytest.mark.parametrize( + "rooms, flat_type, is_studio, expected", + [ + (None, None, True, "studio"), + (0, "Квартира-студия", False, "studio"), + (0, "Квартира", None, "studio"), + (1, "Квартира", None, "1"), + (2, "Квартира", None, "2"), + (3, "Квартира", False, "3"), + (4, "Квартира", False, "4+"), + (5, "Квартира", None, "4+"), + (None, "Квартира", None, "1"), # fallback + ], +) +def test_room_bucket( + rooms: int | None, flat_type: str | None, is_studio: bool | None, expected: str +) -> None: + assert room_bucket_from_flat(rooms, flat_type, is_studio) == expected + + +@pytest.mark.parametrize( + "area, expected", + [ + (0.5, "<25"), + (24.9, "<25"), + (25.0, "25-40"), + (39.99, "25-40"), + (40.0, "40-60"), + (59.99, "40-60"), + (60.0, "60-80"), + (79.99, "60-80"), + (80.0, "80-100"), + (99.99, "80-100"), + (100.0, "100+"), + (250.0, "100+"), + ], +) +def test_area_bin(area: float, expected: str) -> None: + assert area_bin(area) == expected + + +def test_layout_signature_deterministic() -> None: + assert layout_signature("studio", "<25") == "studio__<25" + assert layout_signature("4+", "100+") == "4+__100+" + # Same input → same output + sig1 = layout_signature("2", "40-60") + sig2 = layout_signature("2", "40-60") + assert sig1 == sig2 + + +def test_room_bucket_is_studio_overrides_rooms() -> None: + """is_studio=True beats any rooms value.""" + assert room_bucket_from_flat(3, "Квартира", True) == "studio" + + +def test_room_bucket_flat_type_studio_overrides_rooms() -> None: + """flat_type='Квартира-студия' beats rooms=2.""" + assert room_bucket_from_flat(2, "Квартира-студия", None) == "studio" + + +def test_room_bucket_large_rooms() -> None: + """rooms=10 → '4+'.""" + assert room_bucket_from_flat(10, "Квартира", False) == "4+" + + +def test_area_bin_boundary_exact() -> None: + """Граничные значения попадают в правильный бакет (включение левой границы).""" + assert area_bin(25.0) == "25-40" + assert area_bin(40.0) == "40-60" + assert area_bin(60.0) == "60-80" + assert area_bin(80.0) == "80-100" + assert area_bin(100.0) == "100+" + + +def test_layout_signature_format() -> None: + """Сигнатура всегда содержит двойное подчёркивание-разделитель.""" + sig = layout_signature("1", "25-40") + assert "__" in sig + parts = sig.split("__") + assert parts[0] == "1" + assert parts[1] == "25-40"