feat(parcels): competitors endpoint (Forgejo #112 Phase 1 backend) #191

Merged
lekss361 merged 1 commit from feat/112-competitors-backend into main 2026-05-15 22:21:06 +00:00
4 changed files with 739 additions and 2 deletions

View file

@ -14,7 +14,13 @@ from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.db import get_db
from app.schemas.parcel import ParcelDetail, ParcelSearchRequest, ParcelSearchResponse
from app.schemas.parcel import (
CompetitorsRequest,
CompetitorsResponse,
ParcelDetail,
ParcelSearchRequest,
ParcelSearchResponse,
)
from app.services.site_finder.cadastre_fetch import (
cad_exists_in_db,
find_or_enqueue_fetch,
@ -22,6 +28,7 @@ from app.services.site_finder.cadastre_fetch import (
from app.services.site_finder.cadastre_fetch import (
fetch_status as _fetch_status,
)
from app.services.site_finder.competitors import get_competitors
from app.services.site_finder.gate_verdict import compute_gate_verdict
from app.services.site_finder.quarter_dump_lookup import (
get_quarter_dump_data,
@ -2040,3 +2047,26 @@ def get_isochrones(
"source": "openrouteservice.org",
"note": "Free tier 2000 req/day. Замена на self-hosted OSRM — в #27.",
}
@router.post("/{cad_num}/competitors", response_model=CompetitorsResponse)
async def get_parcel_competitors(
cad_num: str,
body: CompetitorsRequest,
db: Annotated[Session, Depends(get_db)],
) -> CompetitorsResponse:
"""Активные конкуренты ЖК в радиусе от участка (Issue #112).
Возвращает список ЖК из domrf_kn_objects в радиусе radius_km от центроида
участка с рассчитанным velocity_per_month за указанный time_window.
"""
try:
return get_competitors(db=db, cad_num=cad_num, request=body)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except Exception as exc:
logger.error("competitors endpoint failed for %s: %s", cad_num, exc)
raise HTTPException(
status_code=500,
detail="Ошибка расчёта конкурентов",
) from exc

View file

@ -1,4 +1,4 @@
from typing import Any
from typing import Any, Literal
from pydantic import BaseModel, Field
@ -48,3 +48,46 @@ class ParcelSearchResponse(BaseModel):
class ParcelDetail(ParcelSummary):
geometry_geojson: dict[str, Any]
enrichment: dict[str, Any]
# ── Competitors endpoint schemas ──────────────────────────────────────────────
TimeWindow = Literal["last_month", "last_quarter", "last_year"]
ObjClassFilter = Literal["economy", "comfort", "business"]
class CompetitorsRequest(BaseModel):
radius_km: float = Field(default=1.0, ge=0.1, le=1.5)
time_window: TimeWindow = "last_quarter"
obj_class_filter: ObjClassFilter | None = None
exclude_obj_ids: list[int] = Field(default_factory=list)
class Competitor(BaseModel):
obj_id: int
comm_name: str | None
dev_name: str | None
obj_class: str | None
distance_m: float
lat: float
lng: float
stage: str | None
flats_total: int | None
flats_sold: int | None
sold_pct: float | None
velocity_per_month: float
avg_price_per_m2: float | None
is_active: bool
class CompetitorsSummary(BaseModel):
total_competitors: int
active_count: int
weighted_avg_velocity: float
radius_km: float
time_window: str
class CompetitorsResponse(BaseModel):
competitors: list[Competitor]
summary: CompetitorsSummary

View file

@ -0,0 +1,303 @@
"""Анализ активных конкурентов ЖК в радиусе от участка.
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
_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
AND f.status = 'sold'
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)

View file

@ -0,0 +1,361 @@
"""Тесты для POST /api/v1/parcels/{cad_num}/competitors (Issue #112).
Mock-based не требуют живой БД.
Паттерн mock DB: аналогично test_admin_cadastre.py dependency_overrides[get_db].
"""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from fastapi.testclient import TestClient
from app.main import app
# ── Фабрики mock-строк ────────────────────────────────────────────────────────
def _coord_row(lat: float = 56.838, lon: float = 60.605) -> MagicMock:
"""Строка центроида участка."""
r = MagicMock()
r.__getitem__ = lambda self, k: {"lat": lat, "lon": lon}[k]
return r
def _obj_row(
obj_id: int = 1,
distance_m: float = 400.0,
site_status: str = "sales",
obj_class: str | None = "comfort",
velocity: float = 5.0,
flat_count: int | None = 200,
) -> MagicMock:
r = MagicMock()
r.__getitem__ = lambda self, k: {
"obj_id": obj_id,
"comm_name": f"ЖК-{obj_id}",
"dev_name": "TestDev",
"obj_class": obj_class,
"latitude": 56.838 + distance_m / 1_000_000,
"longitude": 60.605 + distance_m / 1_000_000,
"flat_count": flat_count,
"site_status": site_status,
"distance_m": distance_m,
"velocity_per_month": velocity,
}[k]
return r
def _price_row(obj_id: int, price: float) -> MagicMock:
r = MagicMock()
r.__getitem__ = lambda self, k: {"obj_id": obj_id, "avg_price_per_m2": price}[k]
return r
# ── Построение mock DB ────────────────────────────────────────────────────────
def _make_db(
coord: MagicMock | None = None,
obj_rows: list[MagicMock] | None = None,
price_rows: list[MagicMock] | None = None,
) -> MagicMock:
"""Сконструировать mock Session.
Порядок вызовов execute:
1. centroid query coord
2. competitors query obj_rows
3. avg_price query price_rows
"""
db = MagicMock()
results: list[MagicMock] = []
for rows, is_first in [
(coord, True),
(obj_rows or [], False),
(price_rows or [], False),
]:
result = MagicMock()
if is_first:
# centroid → .mappings().first()
result.mappings.return_value.first.return_value = rows
else:
result.mappings.return_value.all.return_value = rows
results.append(result)
db.execute.side_effect = results
return db
def _override_db(db: MagicMock):
def _get_db_override():
yield db
return _get_db_override
# ── Тесты ─────────────────────────────────────────────────────────────────────
def test_competitors_basic() -> None:
"""3 конкурента → корректная форма ответа, сортировка по distance."""
rows = [
_obj_row(obj_id=1, distance_m=200.0, velocity=4.0),
_obj_row(obj_id=2, distance_m=500.0, velocity=6.0),
_obj_row(obj_id=3, distance_m=900.0, velocity=2.0),
]
db = _make_db(coord=_coord_row(), obj_rows=rows)
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
client = TestClient(app)
resp = client.post(
"/api/v1/parcels/66:41:0303161:123/competitors",
json={"radius_km": 1.0, "time_window": "last_quarter"},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert "competitors" in body
assert "summary" in body
assert len(body["competitors"]) == 3
# первый должен быть ближайшим
assert body["competitors"][0]["obj_id"] == 1
assert body["competitors"][0]["distance_m"] == pytest.approx(200.0)
# все поля присутствуют
first = body["competitors"][0]
for key in (
"obj_id",
"comm_name",
"dev_name",
"obj_class",
"distance_m",
"lat",
"lng",
"stage",
"flats_total",
"flats_sold",
"sold_pct",
"velocity_per_month",
"avg_price_per_m2",
"is_active",
):
assert key in first, f"missing key: {key}"
finally:
app.dependency_overrides.clear()
def test_competitors_summary_calc() -> None:
"""summary: total_competitors, active_count, weighted_avg_velocity корректны."""
rows = [
_obj_row(obj_id=1, site_status="sales", velocity=10.0),
_obj_row(obj_id=2, site_status="construction", velocity=6.0),
_obj_row(obj_id=3, site_status="completed", velocity=2.0),
]
db = _make_db(coord=_coord_row(), obj_rows=rows)
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
client = TestClient(app)
resp = client.post(
"/api/v1/parcels/66:41:0303161:5/competitors",
json={"radius_km": 1.0, "time_window": "last_quarter"},
)
assert resp.status_code == 200, resp.text
summary = resp.json()["summary"]
assert summary["total_competitors"] == 3
assert summary["active_count"] == 2 # sales + construction
# avg velocity = (10+6+2)/3 = 6.0
assert summary["weighted_avg_velocity"] == pytest.approx(6.0)
assert summary["radius_km"] == pytest.approx(1.0)
assert summary["time_window"] == "last_quarter"
finally:
app.dependency_overrides.clear()
def test_competitors_exclude_obj_ids() -> None:
"""exclude_obj_ids исключает указанные ЖК из результата."""
rows = [
_obj_row(obj_id=1, distance_m=100.0),
_obj_row(obj_id=2, distance_m=200.0),
_obj_row(obj_id=3, distance_m=300.0),
]
db = _make_db(coord=_coord_row(), obj_rows=rows)
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
client = TestClient(app)
resp = client.post(
"/api/v1/parcels/66:41:0303161:5/competitors",
json={"radius_km": 1.0, "time_window": "last_quarter", "exclude_obj_ids": [2]},
)
assert resp.status_code == 200, resp.text
ids = [c["obj_id"] for c in resp.json()["competitors"]]
assert 2 not in ids
assert 1 in ids
assert 3 in ids
finally:
app.dependency_overrides.clear()
def test_competitors_obj_class_filter() -> None:
"""obj_class_filter=economy — SQL получает параметр; Python-сторона не ломается."""
rows = [_obj_row(obj_id=10, obj_class="economy")]
db = _make_db(coord=_coord_row(), obj_rows=rows)
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
client = TestClient(app)
resp = client.post(
"/api/v1/parcels/66:41:0303161:5/competitors",
json={"radius_km": 1.0, "time_window": "last_quarter", "obj_class_filter": "economy"},
)
assert resp.status_code == 200, resp.text
comps = resp.json()["competitors"]
assert len(comps) == 1
assert comps[0]["obj_class"] == "economy"
finally:
app.dependency_overrides.clear()
def test_competitors_time_window_velocity() -> None:
"""time_window влияет на velocity_per_month (last_month vs last_year)."""
# Здесь проверяем, что endpoint принимает оба варианта без ошибок
# и возвращает velocity из mock-строки (DB-расчёт мокирован).
rows_month = [_obj_row(obj_id=1, velocity=12.0)]
rows_year = [_obj_row(obj_id=1, velocity=3.0)]
from app.core.db import get_db
# last_month
db_m = _make_db(coord=_coord_row(), obj_rows=rows_month)
app.dependency_overrides[get_db] = _override_db(db_m)
try:
client = TestClient(app)
resp_m = client.post(
"/api/v1/parcels/66:41:0303161:5/competitors",
json={"radius_km": 1.0, "time_window": "last_month"},
)
assert resp_m.status_code == 200, resp_m.text
finally:
app.dependency_overrides.clear()
# last_year
db_y = _make_db(coord=_coord_row(), obj_rows=rows_year)
app.dependency_overrides[get_db] = _override_db(db_y)
try:
client = TestClient(app)
resp_y = client.post(
"/api/v1/parcels/66:41:0303161:5/competitors",
json={"radius_km": 1.0, "time_window": "last_year"},
)
assert resp_y.status_code == 200, resp_y.text
v_month = resp_m.json()["competitors"][0]["velocity_per_month"]
v_year = resp_y.json()["competitors"][0]["velocity_per_month"]
# month velocity выше чем year в нашем моке
assert v_month > v_year
finally:
app.dependency_overrides.clear()
def test_competitors_parcel_not_found_404() -> None:
"""Если центроид участка не найден → 404."""
db = _make_db(coord=None) # first() вернёт None
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
client = TestClient(app)
resp = client.post(
"/api/v1/parcels/99:99:9999999:999/competitors",
json={"radius_km": 1.0, "time_window": "last_quarter"},
)
assert resp.status_code == 404, resp.text
finally:
app.dependency_overrides.clear()
def test_competitors_empty_radius() -> None:
"""Нет конкурентов в радиусе → пустой список + summary с нулями."""
db = _make_db(coord=_coord_row(), obj_rows=[])
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
client = TestClient(app)
resp = client.post(
"/api/v1/parcels/66:41:0303161:5/competitors",
json={"radius_km": 0.1, "time_window": "last_quarter"},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["competitors"] == []
assert body["summary"]["total_competitors"] == 0
assert body["summary"]["active_count"] == 0
assert body["summary"]["weighted_avg_velocity"] == pytest.approx(0.0)
finally:
app.dependency_overrides.clear()
def test_competitors_sold_pct_null() -> None:
"""sold_pct и flats_sold — None (MVP: данные недоступны из domrf_kn_objects).
Полный расчёт продаж требует JOIN с domrf_kn_flats COUNT WHERE status='sold'
отложен за пределы текущего PR.
"""
rows = [_obj_row(obj_id=1, flat_count=200)]
db = _make_db(coord=_coord_row(), obj_rows=rows)
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
client = TestClient(app)
resp = client.post(
"/api/v1/parcels/66:41:0303161:5/competitors",
json={"radius_km": 1.0, "time_window": "last_quarter"},
)
assert resp.status_code == 200, resp.text
comp = resp.json()["competitors"][0]
assert comp["flats_sold"] is None
assert comp["sold_pct"] is None
assert comp["flats_total"] == 200
finally:
app.dependency_overrides.clear()
def test_competitors_is_active_flag() -> None:
"""is_active=True для sales/construction, False для completed/null."""
rows = [
_obj_row(obj_id=1, site_status="sales"),
_obj_row(obj_id=2, site_status="construction"),
_obj_row(obj_id=3, site_status="completed"),
]
db = _make_db(coord=_coord_row(), obj_rows=rows)
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
client = TestClient(app)
resp = client.post(
"/api/v1/parcels/66:41:0303161:5/competitors",
json={"radius_km": 1.0, "time_window": "last_quarter"},
)
assert resp.status_code == 200, resp.text
comps = {c["obj_id"]: c for c in resp.json()["competitors"]}
assert comps[1]["is_active"] is True
assert comps[2]["is_active"] is True
assert comps[3]["is_active"] is False
finally:
app.dependency_overrides.clear()