#4 Карточка дома показывала cross-площадочные дубли (Искровцев 33: 2 квартиры = 4 строки avito/domklik/cian). build_listings_query теперь дедуплит по сигнатуре (rooms, round(area_m2), floor) — как v_building_sale_share мигр.148 — одна строка на квартиру, представитель = самый дешёвый, +колонка sources[] (площадки). Гео-фильтр ≤300м (мигр.150) сохранён. +BuildingListing.sources в схеме. P4a Мигр.151: DELETE 6338/21720 голых алиасов house_address_aliases (без номера дома) — сток, в который сваливались безномерные листинги (Tier-2b). Матчер уже захардненен (P1 #2082), голые не регенерируют. Алиасы — регенерируемый кэш, house_id_fk/house_sources не тронуты. ruff clean, pytest test_buildings_api 26 green.
94 lines
3.7 KiB
Python
94 lines
3.7 KiB
Python
"""Pydantic-схемы страницы «доля квартир дома в продаже» (мигр. 143).
|
||
|
||
Источник данных — view v_building_sale_share + базовая таблица listings.
|
||
Список домов вторичного рынка, где active_secondary / flat_count_effective >= threshold%.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import date
|
||
|
||
from pydantic import BaseModel, ConfigDict
|
||
|
||
|
||
class BuildingSaleShare(BaseModel):
|
||
"""Один дом из v_building_sale_share для списка /buildings/sale-share."""
|
||
|
||
model_config = ConfigDict(from_attributes=True)
|
||
|
||
house_id: int
|
||
address: str | None = None
|
||
lat: float | None = None
|
||
lon: float | None = None
|
||
|
||
sale_share_pct: float | None = None
|
||
# 2-й процент: distinct vtorichka за последние 45д (активные ИЛИ недавно
|
||
# закрытые) / знаменатель. Дом проходит фильтр по ЛЮБОМУ из двух процентов.
|
||
sale_share_pct_45d: float | None = None
|
||
# implausible-матч (числитель > знаменателя по любому из %) — скорее всего
|
||
# коллизия адреса при ГАР-матче; не прячем, а маркируем для UI.
|
||
over_100: bool = False
|
||
|
||
active_secondary: int | None = None
|
||
# distinct vtorichka-листинги (активные ИЛИ закрытые) за окно 45д.
|
||
listings_45d: int | None = None
|
||
flat_count_effective: int | None = None
|
||
gar_match_method: str | None = None
|
||
|
||
median_price_rub: int | None = None
|
||
median_price_per_m2: int | None = None
|
||
avg_days_on_market: float | None = None
|
||
|
||
year_built: int | None = None
|
||
house_type: str | None = None
|
||
total_floors: int | None = None
|
||
series_name: str | None = None
|
||
is_emergency: bool | None = None
|
||
|
||
|
||
class BuildingListing(BaseModel):
|
||
"""Активное вторичное объявление в доме (click-through drawer)."""
|
||
|
||
model_config = ConfigDict(from_attributes=True)
|
||
|
||
listing_id: int
|
||
source: str
|
||
source_url: str | None = None
|
||
price_rub: int | None = None
|
||
price_per_m2: int | None = None
|
||
rooms: int | None = None
|
||
area_m2: float | None = None
|
||
floor: int | None = None
|
||
total_floors: int | None = None
|
||
days_on_market: int | None = None
|
||
listing_date: date | None = None
|
||
address: str | None = None
|
||
# Площадки, где видна эта физическая квартира (дедуп кросс-площадочных дублей
|
||
# по сигнатуре (rooms, round(area_m2), floor) — мигр.148). Представитель строки —
|
||
# самый дешёвый листинг; sources перечисляет все источники той же квартиры.
|
||
sources: list[str] | None = None
|
||
|
||
|
||
class HistogramBucket(BaseModel):
|
||
"""Корзина гистограммы распределения sale_share_pct."""
|
||
|
||
bucket: str
|
||
count: int
|
||
|
||
|
||
class SaleShareSummary(BaseModel):
|
||
"""Сводка для слайдера % + баннера покрытия (/buildings/sale-share/summary).
|
||
|
||
buildings_with_denominator/coverage считают дома с непустым ЛЮБЫМ из двух %.
|
||
max/p95 и histogram — отдельно для каждого процента (now + 45д).
|
||
"""
|
||
|
||
total_secondary_buildings: int
|
||
buildings_with_denominator: int
|
||
coverage_pct: float
|
||
max_pct: float | None = None
|
||
p95_pct: float | None = None
|
||
max_pct_45d: float | None = None
|
||
p95_pct_45d: float | None = None
|
||
histogram: list[HistogramBucket]
|
||
histogram_45d: list[HistogramBucket]
|