parent
4deb1b0284
commit
e673811a20
3 changed files with 236 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
58
backend/app/services/site_finder/layout_signature.py
Normal file
58
backend/app/services/site_finder/layout_signature.py
Normal file
|
|
@ -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}"
|
||||
97
backend/tests/test_layout_signature.py
Normal file
97
backend/tests/test_layout_signature.py
Normal file
|
|
@ -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"
|
||||
Loading…
Add table
Reference in a new issue