domrf_kn_flats.status is NULL in ~99.8% of rows, so WHERE status='sold' always returned 0 rows and avg_price_per_m2 was always None. Drop the filter; AVG over all rows with price_per_m2 IS NOT NULL is semantically correct for a complex-level price estimate. Adds regression test test_competitors_avg_price_populated (Issue #227).
305 lines
12 KiB
Python
305 lines
12 KiB
Python
"""Анализ активных конкурентов ЖК в радиусе от участка.
|
||
|
||
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)
|