From e561df1b55031a7a6cee41e594b2af92f5de2f58 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Fri, 15 May 2026 22:21:05 +0000 Subject: [PATCH 1/2] feat(parcels): competitors endpoint (Forgejo #112 Phase 1 backend) (#191) --- backend/app/api/v1/parcels.py | 32 +- backend/app/schemas/parcel.py | 45 ++- .../app/services/site_finder/competitors.py | 303 +++++++++++++++ .../tests/api/v1/test_parcel_competitors.py | 361 ++++++++++++++++++ 4 files changed, 739 insertions(+), 2 deletions(-) create mode 100644 backend/app/services/site_finder/competitors.py create mode 100644 backend/tests/api/v1/test_parcel_competitors.py 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() From e97f96afa77cb8297d8ab138072771d2ab1feff2 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 16 May 2026 05:25:00 +0000 Subject: [PATCH 2/2] fix(ci): switch deploy to .forgejo/workflows + shell GHCR login (#192) --- {.github => .forgejo}/workflows/deploy.yml | 122 ++++++++------------- 1 file changed, 43 insertions(+), 79 deletions(-) rename {.github => .forgejo}/workflows/deploy.yml (58%) diff --git a/.github/workflows/deploy.yml b/.forgejo/workflows/deploy.yml similarity index 58% rename from .github/workflows/deploy.yml rename to .forgejo/workflows/deploy.yml index 09b9254a..e9189f88 100644 --- a/.github/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -1,7 +1,9 @@ name: Deploy -# Деплоится только при изменениях основного стека. -# Obsidian-стек (CouchDB) — отдельный workflow `deploy-obsidian.yml`. +# Forgejo Actions equivalent of .github/workflows/deploy.yml +# Migration 2026-05-16: GitHub → Forgejo (git.gendsgn.ru) +# Builds images on Forgejo runner, pushes to ghcr.io (GitHub Container Registry), +# SSH-deploys to Beget VPS (46.173.16.127). on: push: branches: [main] @@ -10,10 +12,8 @@ on: - "frontend/**" - "docker-compose.prod.yml" - "Caddyfile" - - ".github/workflows/deploy.yml" + - ".forgejo/workflows/deploy.yml" - "data/sql/*.sql" - # NB: shared compose-fragments (network) — тоже триггерят main - # потому что Caddy шарит сеть с obsidian-stack workflow_dispatch: concurrency: @@ -28,9 +28,6 @@ env: jobs: changes: runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read outputs: backend: ${{ steps.filter.outputs.backend }} frontend: ${{ steps.filter.outputs.frontend }} @@ -43,19 +40,17 @@ jobs: filters: | backend: - 'backend/**' + - 'data/sql/**' frontend: - 'frontend/**' infra: - 'docker-compose.prod.yml' - 'Caddyfile' - - '.github/workflows/deploy.yml' + - '.forgejo/workflows/deploy.yml' build-backend: runs-on: ubuntu-latest needs: changes - permissions: - contents: read - packages: write if: | needs.changes.outputs.backend == 'true' || needs.changes.outputs.infra == 'true' || @@ -63,12 +58,11 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Login to GHCR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + - name: Login to GHCR (shell-based — docker/login-action@v3 unreliable под Forgejo Actions) + env: + GHCR_PAT: ${{ secrets.GHCR_PAT }} + run: | + echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -79,8 +73,8 @@ jobs: context: ./backend target: runner push: true - cache-from: type=gha,scope=backend-lean - cache-to: type=gha,mode=max,scope=backend-lean + cache-from: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache + cache-to: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache,mode=max tags: | ${{ env.IMAGE_BACKEND }}:latest ${{ env.IMAGE_BACKEND }}:${{ github.sha }} @@ -88,9 +82,6 @@ jobs: build-worker: runs-on: ubuntu-latest needs: changes - permissions: - contents: read - packages: write if: | needs.changes.outputs.backend == 'true' || needs.changes.outputs.infra == 'true' || @@ -98,12 +89,11 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Login to GHCR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + - name: Login to GHCR (shell-based — docker/login-action@v3 unreliable под Forgejo Actions) + env: + GHCR_PAT: ${{ secrets.GHCR_PAT }} + run: | + echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -114,8 +104,8 @@ jobs: context: ./backend target: runner-with-chromium push: true - cache-from: type=gha,scope=worker-chromium - cache-to: type=gha,mode=max,scope=worker-chromium + cache-from: type=registry,ref=${{ env.IMAGE_WORKER }}:buildcache + cache-to: type=registry,ref=${{ env.IMAGE_WORKER }}:buildcache,mode=max tags: | ${{ env.IMAGE_WORKER }}:latest ${{ env.IMAGE_WORKER }}:${{ github.sha }} @@ -123,9 +113,6 @@ jobs: build-frontend: runs-on: ubuntu-latest needs: changes - permissions: - contents: read - packages: write if: | needs.changes.outputs.frontend == 'true' || needs.changes.outputs.infra == 'true' || @@ -133,12 +120,11 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Login to GHCR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + - name: Login to GHCR (shell-based — docker/login-action@v3 unreliable под Forgejo Actions) + env: + GHCR_PAT: ${{ secrets.GHCR_PAT }} + run: | + echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -148,8 +134,8 @@ jobs: with: context: ./frontend push: true - cache-from: type=gha,scope=frontend - cache-to: type=gha,mode=max,scope=frontend + cache-from: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache + cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max tags: | ${{ env.IMAGE_FRONTEND }}:latest ${{ env.IMAGE_FRONTEND }}:${{ github.sha }} @@ -157,10 +143,6 @@ jobs: deploy: runs-on: ubuntu-latest needs: [changes, build-backend, build-worker, build-frontend] - permissions: - contents: read - # Запускается если ни один build не завершился failure. - # `always()` позволяет выполнить job даже когда зависимые jobs были skipped. if: | always() && !cancelled() && @@ -173,26 +155,26 @@ jobs: env: IMAGE_TAG: latest SENTRY_RELEASE_VAL: ${{ github.sha }} + GHCR_PAT: ${{ secrets.GHCR_PAT }} with: host: ${{ secrets.DEPLOY_HOST }} username: ${{ secrets.DEPLOY_USER }} key: ${{ secrets.DEPLOY_SSH_KEY }} - port: ${{ secrets.DEPLOY_PORT || 22 }} - envs: IMAGE_TAG,SENTRY_RELEASE_VAL + port: ${{ secrets.DEPLOY_PORT }} + envs: IMAGE_TAG,SENTRY_RELEASE_VAL,GHCR_PAT script: | set -euo pipefail cd /opt/gendesign # Sync compose / Caddyfile / init scripts from the repo. - # --ff-only refuses if there are local commits on the VM (we don't expect any). + # Origin теперь Forgejo (после migration 2026-05-16) — HTTPS basic auth + # через PAT в URL не нужен т.к. repo читается через deploy key (SSH) + # ИЛИ public read-only mode. Forgejo gendesign — private, нужен auth: + # `git remote get-url origin` должен указывать на Forgejo с auth. git fetch origin main git reset --hard origin/main - # Sentry release tracking — записываем git-sha в .env.runtime. - # ВАЖНО: НЕ перезаписываем файл целиком (там могут быть - # COUCHDB_PASSWORD и др. user-managed secrets). Заменяем только - # строку SENTRY_RELEASE или добавляем если её нет. - # IMAGE_TAG=latest для docker pull; SENTRY_RELEASE_VAL = git sha для трекинга. + # Sentry release tracking mkdir -p backend touch backend/.env.runtime if grep -q '^SENTRY_RELEASE=' backend/.env.runtime; then @@ -202,27 +184,19 @@ jobs: fi chmod 600 backend/.env.runtime - # Создать external network если её нет (нужна Caddy для маршрута - # obsidian.gendsgn.ru → couchdb из отдельного obsidian-stack). + # External network для Caddy + obsidian-stack share docker network inspect gendesign_shared >/dev/null 2>&1 \ || docker network create gendesign_shared + # Re-login to GHCR (PAT может быть rotated после initial setup) + echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin + export IMAGE_TAG="$IMAGE_TAG" - # Project name явно — `gendesign` (по имени папки auto), но - # фиксируем для consistency с obsidian-stack (-p gendesign-obsidian). docker compose -p gendesign -f docker-compose.prod.yml pull - # ── Apply pending SQL migrations ────────────────────────────────── - # Tracking table: _schema_migrations (filename TEXT PRIMARY KEY). - # Each NN_*.sql file is applied exactly once, in sort order. - # ON_ERROR_STOP=on ensures a failing migration aborts the deploy. - # POSTGRES_USER/PASSWORD/DB живут в /opt/gendesign/.env (root — - # их читает compose через env_file для postgres service). - # backend/.env содержит ТОЛЬКО backend-app vars (SCRAPE_ADMIN_TOKEN, - # OPENROUTESERVICE_API_KEY, etc), без POSTGRES_*. + # Apply pending SQL migrations set -a; source .env; set +a - # Ensure tracking table exists (idempotent). docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \ psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=on -c " CREATE TABLE IF NOT EXISTS _schema_migrations ( @@ -231,7 +205,6 @@ jobs: ); " - # Apply each pending .sql file in NN order. for sql_file in $(ls -1 data/sql/*.sql 2>/dev/null | sort); do fname=$(basename "$sql_file") applied=$(docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \ @@ -251,18 +224,13 @@ jobs: fi done echo "All migrations applied." - # ───────────────────────────────────────────────────────────────── docker compose -p gendesign -f docker-compose.prod.yml up -d - # Caddyfile is bind-mounted; up -d won't re-read it. Reload explicitly. - # `|| true` so a temporary Caddy hiccup doesn't fail the whole deploy. docker compose -p gendesign -f docker-compose.prod.yml exec -T caddy \ caddy reload --config /etc/caddy/Caddyfile || true - # Clean up disk — aggressive чтобы избежать накопления. - # 1. Для каждого gendesign-* repo оставляем 2 самых свежих SHA-тега - # + :latest. Docker images сортирует по Created DESC. + # Cleanup old images for repo in ghcr.io/lekss361/gendesign-backend \ ghcr.io/lekss361/gendesign-worker \ ghcr.io/lekss361/gendesign-frontend; do @@ -271,14 +239,10 @@ jobs: | tail -n +3 \ | xargs -r docker rmi 2>/dev/null || true done - # 2. ВСЕ unused images (даже свежие). Running контейнеры безопасны - # — их images не unused. Раньше был filter until=72h, но при - # частых деплоях оставались "fresh dangling" layers, накапливая - # до 50GB за неделю. docker image prune -af || true docker builder prune -af || true - # Wait for backend to be healthy (up to 30 s). + # Health check for i in $(seq 1 30); do curl -fsS http://localhost:8000/health && break sleep 1