diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 63a3cfba..26c6ea8a 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -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 diff --git a/backend/app/schemas/parcel.py b/backend/app/schemas/parcel.py index 20e2c30c..8f1b256d 100644 --- a/backend/app/schemas/parcel.py +++ b/backend/app/schemas/parcel.py @@ -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 diff --git a/backend/app/services/site_finder/competitors.py b/backend/app/services/site_finder/competitors.py new file mode 100644 index 00000000..cfba9d81 --- /dev/null +++ b/backend/app/services/site_finder/competitors.py @@ -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) diff --git a/backend/tests/api/v1/test_parcel_competitors.py b/backend/tests/api/v1/test_parcel_competitors.py new file mode 100644 index 00000000..eacef554 --- /dev/null +++ b/backend/tests/api/v1/test_parcel_competitors.py @@ -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()