"""Анализ активных конкурентов ЖК в радиусе от участка. Issue #112 — Demand: активные конкуренты, продажи ЖК в радиусе 1км за квартал. Источники: domrf_kn_objects — ЖК с lat/lon, flat_count, obj_class, site_status objective_complex_mapping — domrf_obj_id → objective_complex_name objective_corpus_room_month — monthly deals_total_count per project_name cad_parcels_geom — centroid участка (fallback: cad_quarters_geom) domrf_kn_flats — avg price_per_m2 по проданным квартирам Внимание: velocity coverage ~2.5% — большинство ЖК не имеют маппинга в objective_complex_mapping. LEFT JOIN гарантирует velocity=0 (не ошибку) для немаппированных объектов. """ from __future__ import annotations import logging from sqlalchemy import text from sqlalchemy.orm import Session from app.schemas.parcel import ( Competitor, CompetitorsRequest, CompetitorsResponse, CompetitorsSummary, ) logger = logging.getLogger(__name__) # Маппинг time_window → число месяцев (float для деления velocity) _TIME_WINDOW_MONTHS: dict[str, float] = { "last_month": 1.0, "last_quarter": 3.0, "last_year": 12.0, } # site_status значения, считающиеся «активными» _ACTIVE_STATUSES = frozenset({"sales", "construction"}) # SQL для получения центроида участка _PARCEL_CENTROID_SQL = text(""" SELECT ST_X(pt) AS lon, ST_Y(pt) AS lat FROM ( SELECT ST_Centroid(geom) AS pt FROM cad_parcels_geom WHERE cad_num = :cad_num AND geom IS NOT NULL UNION ALL SELECT ST_Centroid(geom) AS pt FROM cad_quarters_geom WHERE cad_number = :quarter AND geom IS NOT NULL ) sub LIMIT 1 """) # Основной запрос конкурентов в радиусе. # Velocity через objective_corpus_room_month (актуальные данные, обновляется еженедельно). # domrf_kn_sale_graph устарел (данные до 2026-01) — не используется. # Coverage velocity ~2.5%: большинство obj_id нет в objective_complex_mapping → # LEFT JOIN → velocity=0 (не ошибка). _COMPETITORS_SQL = text(""" WITH latest_obj AS ( SELECT DISTINCT ON (obj_id) obj_id, comm_name, dev_name, obj_class, latitude, longitude, flat_count, site_status, snapshot_date FROM domrf_kn_objects WHERE latitude IS NOT NULL AND longitude IS NOT NULL ORDER BY obj_id, snapshot_date DESC NULLS LAST ), mapped AS ( SELECT cm.domrf_obj_id AS obj_id, cm.objective_complex_name FROM objective_complex_mapping cm ), velocity AS ( SELECT m.obj_id, SUM(COALESCE(crm.deals_total_count, 0)) / CAST(:time_window_months AS float) AS velocity_per_month FROM objective_corpus_room_month crm JOIN mapped m ON m.objective_complex_name = crm.project_name WHERE crm.report_month >= (NOW() - CAST(:window_interval AS interval)) GROUP BY m.obj_id ), distances AS ( SELECT o.obj_id, o.comm_name, o.dev_name, o.obj_class, o.latitude, o.longitude, o.flat_count, o.site_status, ST_Distance( ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography, ST_SetSRID( ST_MakePoint(CAST(:center_lon AS float), CAST(:center_lat AS float)), 4326 )::geography ) AS distance_m FROM latest_obj o ) SELECT d.obj_id, d.comm_name, d.dev_name, d.obj_class, d.latitude, d.longitude, d.flat_count, d.site_status, d.distance_m, COALESCE(v.velocity_per_month, 0.0) AS velocity_per_month FROM distances d LEFT JOIN velocity v ON v.obj_id = d.obj_id WHERE d.distance_m <= CAST(:radius_m AS float) AND ( CAST(:obj_class_filter AS text) IS NULL OR d.obj_class = CAST(:obj_class_filter AS text) ) ORDER BY d.distance_m ASC """) # Средняя цена м² по квартирам с известной ценой для набора obj_id. # Фильтр status='sold' убран: поле status в domrf_kn_flats заполнено в ~0.2% строк # (99.8% NULL) — фильтр давал 0 строк и avg_price_per_m2 всегда None (Issue #112/227). # AVG по всем квартирам с price_per_m2 IS NOT NULL даёт корректную среднюю цену ЖК. _AVG_PRICE_SQL = text(""" SELECT f.obj_id, AVG(f.price_per_m2) AS avg_price_per_m2 FROM domrf_kn_flats f WHERE f.obj_id = ANY(:obj_ids) AND f.price_per_m2 IS NOT NULL GROUP BY f.obj_id """) def _quarter_from_cad(cad_num: str) -> str: """Извлечь кадастровый квартал из номера участка/здания. 66:41:0303161:123 → 66:41:0303161 Если формат нестандартный — возвращаем cad_num как есть (fallback). """ parts = cad_num.split(":") if len(parts) >= 3: return ":".join(parts[:3]) return cad_num def get_competitors( db: Session, cad_num: str, request: CompetitorsRequest, ) -> CompetitorsResponse: """Получить список конкурентов ЖК в радиусе от участка. Шаги: 1. Найти центроид участка (cad_parcels_geom → cad_quarters_geom fallback). 2. Выбрать ЖК из domrf_kn_objects в радиусе с velocity из objective_corpus_room_month. 3. Применить exclude_obj_ids фильтр в Python (избегаем array cast). 4. Подтянуть avg_price_per_m2 из domrf_kn_flats. 5. Собрать CompetitorsResponse. Raises: ValueError: если центроид участка не найден (caller должен вернуть 404). """ quarter = _quarter_from_cad(cad_num) # ── 1. Центроид участка ────────────────────────────────────────────────── try: coord_row = ( db.execute( _PARCEL_CENTROID_SQL, {"cad_num": cad_num, "quarter": quarter}, ) .mappings() .first() ) except Exception: logger.exception("competitors: centroid query failed for cad_num=%s", cad_num) raise if not coord_row: raise ValueError(f"Геометрия для {cad_num} не найдена") center_lat = float(coord_row["lat"]) center_lon = float(coord_row["lon"]) # ── 2. Конкуренты в радиусе ────────────────────────────────────────────── time_window_months = _TIME_WINDOW_MONTHS[request.time_window] window_interval = f"{int(time_window_months)} months" try: rows = ( db.execute( _COMPETITORS_SQL, { "center_lat": center_lat, "center_lon": center_lon, "radius_m": request.radius_km * 1000.0, "time_window_months": time_window_months, "window_interval": window_interval, "obj_class_filter": request.obj_class_filter, }, ) .mappings() .all() ) except Exception: logger.exception( "competitors: main query failed for cad_num=%s radius_km=%.2f", cad_num, request.radius_km, ) raise # ── 3. Применить exclude_obj_ids ───────────────────────────────────────── exclude_set = set(request.exclude_obj_ids) if exclude_set: rows = [r for r in rows if int(r["obj_id"]) not in exclude_set] if not rows: return CompetitorsResponse( competitors=[], summary=CompetitorsSummary( total_competitors=0, active_count=0, weighted_avg_velocity=0.0, radius_km=request.radius_km, time_window=request.time_window, ), ) obj_ids: list[int] = [int(r["obj_id"]) for r in rows] # ── 4. Средняя цена м² (graceful — таблица может быть не заполнена) ────── avg_price_map: dict[int, float] = {} try: price_rows = db.execute(_AVG_PRICE_SQL, {"obj_ids": obj_ids}).mappings().all() avg_price_map = { int(r["obj_id"]): float(r["avg_price_per_m2"]) for r in price_rows if r["avg_price_per_m2"] is not None } except Exception: logger.warning("competitors: avg_price query failed, continuing without prices") # ── 5. Сборка результата ───────────────────────────────────────────────── # flats_sold / sold_pct: не доступны из domrf_kn_objects (только flat_count). # Можно получить через COUNT(domrf_kn_flats WHERE status='sold') — # отложено за MVP, поля остаются None. competitors: list[Competitor] = [] for r in rows: obj_id = int(r["obj_id"]) flats_total = int(r["flat_count"]) if r["flat_count"] is not None else None site_status = r["site_status"] is_active = site_status in _ACTIVE_STATUSES if site_status else False competitors.append( Competitor( obj_id=obj_id, comm_name=r["comm_name"], dev_name=r["dev_name"], obj_class=r["obj_class"], distance_m=round(float(r["distance_m"]), 1), lat=float(r["latitude"]), lng=float(r["longitude"]), stage=site_status, flats_total=flats_total, flats_sold=None, sold_pct=None, velocity_per_month=round(float(r["velocity_per_month"]), 2), avg_price_per_m2=avg_price_map.get(obj_id), is_active=is_active, ) ) # ── 6. Summary ─────────────────────────────────────────────────────────── active_count = sum(1 for c in competitors if c.is_active) total_velocity = sum(c.velocity_per_month for c in competitors) n = len(competitors) weighted_avg_velocity = round(total_velocity / n, 2) if n > 0 else 0.0 summary = CompetitorsSummary( total_competitors=n, active_count=active_count, weighted_avg_velocity=weighted_avg_velocity, radius_km=request.radius_km, time_window=request.time_window, ) return CompetitorsResponse(competitors=competitors, summary=summary)