From 50547dcbb3bd258c4fff7c11550f20a9b2c29b7e Mon Sep 17 00:00:00 2001 From: lekss361 Date: Fri, 15 May 2026 21:55:04 +0000 Subject: [PATCH 001/342] test(deploy): validate Forgejo Actions end-to-end flow (#189) --- Caddyfile | 1 + backend/tests/services/test_cadastre_bulk.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Caddyfile b/Caddyfile index 20683b1b..5c52cc04 100644 --- a/Caddyfile +++ b/Caddyfile @@ -61,3 +61,4 @@ obsidian.gendsgn.ru { reverse_proxy frontend:3000 } } +# Test deploy flow 2026-05-15T21:43:32Z diff --git a/backend/tests/services/test_cadastre_bulk.py b/backend/tests/services/test_cadastre_bulk.py index 1252ccc2..952a8891 100644 --- a/backend/tests/services/test_cadastre_bulk.py +++ b/backend/tests/services/test_cadastre_bulk.py @@ -1162,7 +1162,7 @@ async def test_grid_walk_emits_heartbeat_callbacks() -> None: progress_states: list[dict[str, Any]] = [] - upserted, requests = await _grid_walk_category( + _upserted, requests = await _grid_walk_category( db=db, client=client, quarter="66:41:0303161", @@ -1197,7 +1197,7 @@ async def test_grid_walk_no_heartbeat_when_callback_none() -> None: client.wms_feature_info = AsyncMock(return_value=[]) # Не передаём update_progress — должно не падать - upserted, requests = await _grid_walk_category( + _upserted, requests = await _grid_walk_category( db=db, client=client, quarter="66:41:0303161", From e561df1b55031a7a6cee41e594b2af92f5de2f58 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Fri, 15 May 2026 22:21:05 +0000 Subject: [PATCH 002/342] 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 003/342] 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 From 4deb1b02842116864b0a8560c70a58316ac0b447 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 16 May 2026 05:58:27 +0000 Subject: [PATCH 004/342] feat(parcels): connection-points endpoint (Forgejo #115 Phase 1 backend) (#190) --- backend/app/api/v1/parcels.py | 36 +++ backend/app/schemas/parcel.py | 41 +++ .../site_finder/quarter_dump_lookup.py | 299 ++++++++++++++++++ .../api/v1/test_parcel_connection_points.py | 267 ++++++++++++++++ 4 files changed, 643 insertions(+) create mode 100644 backend/tests/api/v1/test_parcel_connection_points.py diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 26c6ea8a..9979df85 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -17,6 +17,7 @@ from app.core.db import get_db from app.schemas.parcel import ( CompetitorsRequest, CompetitorsResponse, + ConnectionPointsResponse, ParcelDetail, ParcelSearchRequest, ParcelSearchResponse, @@ -31,6 +32,7 @@ from app.services.site_finder.cadastre_fetch import ( 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_connection_points, get_quarter_dump_data, make_empty_result, ) @@ -1930,6 +1932,40 @@ def analyze_parcel( } +@router.get( + "/{cad_num}/connection-points", + response_model=ConnectionPointsResponse, + summary="Точки подключения к инженерным сетям + охранные зоны (issue #115)", +) +async def get_parcel_connection_points( + cad_num: str, + db: Annotated[Session, Depends(get_db)], + radius_m: Annotated[int, Query(ge=50, le=2000)] = 500, +) -> ConnectionPointsResponse: + """Инженерные структуры (ТП, ЦТП, ЛЭП) и охранные зоны коммуникаций вблизи участка. + + Источник данных: nspd_quarter_dumps (НСПД cat 36328 / 37578). + Если дамп для квартала ещё не загружен → dump_available=false, + пустые массивы (harvest запускается автоматически). + Если участок не найден в БД (нет geom) → 404. + + Query params: + radius_m: радиус поиска инженерных структур, 50–2000 м (default 500). + """ + try: + data = get_connection_points(db, cad_num, radius_m) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + return ConnectionPointsResponse( + engineering_structures=data["engineering_structures"], + zouit_engineering_overlaps=data["zouit_engineering_overlaps"], + summary=data["summary"], + dump_available=data["dump_available"], + dump_fetched_at=data["dump_fetched_at"], + ) + + _ORS_BASE = "https://api.openrouteservice.org/v2/isochrones" _ORS_VALID_MODES = frozenset({"foot-walking", "cycling-regular", "driving-car"}) diff --git a/backend/app/schemas/parcel.py b/backend/app/schemas/parcel.py index 8f1b256d..c4e7e3ae 100644 --- a/backend/app/schemas/parcel.py +++ b/backend/app/schemas/parcel.py @@ -2,6 +2,47 @@ from typing import Any, Literal from pydantic import BaseModel, Field +# ── Connection points schemas (issue #115) ──────────────────────────────────── + + +class EngineeringStructure(BaseModel): + name: str | None + type: str | None + cad_num: str | None + distance_to_boundary_m: float + geometry_geojson: dict[str, Any] + readable_address: str | None + raw_props: dict[str, Any] + source: str + + +class ZouitOverlap(BaseModel): + reg_numb_border: str | None + type_zone: str | None + subcategory: int | None + intersects_parcel: bool + geometry_geojson: dict[str, Any] + raw_props: dict[str, Any] + source: str + + +class ConnectionPointsSummary(BaseModel): + nearest_structure_distance_m: float | None + in_protection_zone: bool + protection_zones_intersecting: int + total_structures_in_radius: int + + +class ConnectionPointsResponse(BaseModel): + engineering_structures: list[EngineeringStructure] + zouit_engineering_overlaps: list[ZouitOverlap] + summary: ConnectionPointsSummary + dump_available: bool + dump_fetched_at: str | None + + +# ── Parcel search/detail schemas ────────────────────────────────────────────── + class ParcelFilter(BaseModel): vri: list[str] | None = None diff --git a/backend/app/services/site_finder/quarter_dump_lookup.py b/backend/app/services/site_finder/quarter_dump_lookup.py index 72bd03ab..ce5d9213 100644 --- a/backend/app/services/site_finder/quarter_dump_lookup.py +++ b/backend/app/services/site_finder/quarter_dump_lookup.py @@ -13,11 +13,13 @@ harvest_quarter.apply_async() и продолжает без dump-derived пол from __future__ import annotations +import json import logging from datetime import UTC, datetime, timedelta from typing import Any from sqlalchemy import text +from sqlalchemy.exc import OperationalError, ProgrammingError from sqlalchemy.orm import Session logger = logging.getLogger(__name__) @@ -392,6 +394,303 @@ def _get_engineering_nearby( return [] +# ── Connection-points lookup (issue #115) ──────────────────────────────────── + + +def get_connection_points(db: Session, cad_num: str, radius_m: int = 500) -> dict[str, Any]: + """Получить точки инженерных подключений в radius_m от boundary участка. + + Источник: `nspd_quarter_dumps.features_json` для квартала cad_num — + layers `engineering_structures` (NSPD cat 36328, ТП/ЦТП/насосные/опоры ЛЭП) + и `zouit_engineering` (NSPD cat 37578 — охранные зоны инжен.коммуникаций). + + Args: + db: SQLAlchemy session. + cad_num: кадастровый номер участка (e.g. '66:41:0204016:10'). + radius_m: радиус поиска в метрах от boundary участка (default 500). + + Returns: + { + "engineering_structures": [...], + "zouit_engineering_overlaps": [...], + "summary": {...}, + "dump_available": bool, + "dump_fetched_at": str | None, + } + + Raises: + ValueError: если parcel не найден в cad_parcels_geom / cad_quarters_geom. + """ + quarter = derive_quarter_cad(cad_num) + if quarter is None: + raise ValueError(f"Невалидный формат кадастрового номера: {cad_num!r}") + + # Получаем WKT геометрию участка (boundary, не centroid) + parcel_wkt = _get_parcel_wkt(db, cad_num) + if parcel_wkt is None: + raise ValueError(f"Участок {cad_num!r} не найден в БД") + + # Проверяем наличие дампа + dump_row = db.execute( + text( + """ + SELECT fetched_at_utc, total_features + FROM nspd_quarter_dumps + WHERE quarter_cad = :q + ORDER BY fetched_at_utc DESC + LIMIT 1 + """ + ), + {"q": quarter}, + ).first() + + if dump_row is None: + _trigger_harvest(quarter) + return { + "engineering_structures": [], + "zouit_engineering_overlaps": [], + "summary": { + "nearest_structure_distance_m": None, + "in_protection_zone": False, + "protection_zones_intersecting": 0, + "total_structures_in_radius": 0, + }, + "dump_available": False, + "dump_fetched_at": None, + } + + fetched_at = dump_row[0] + if fetched_at is not None and getattr(fetched_at, "isoformat", None): + dump_fetched_at: str | None = fetched_at.isoformat() + else: + dump_fetched_at = str(fetched_at) if fetched_at is not None else None + + structures = _get_engineering_structures_by_boundary(db, quarter, parcel_wkt, radius_m) + zouit_overlaps = _get_zouit_engineering_overlaps(db, quarter, parcel_wkt) + + nearest_dist: float | None = structures[0]["distance_to_boundary_m"] if structures else None + protection_count = len(zouit_overlaps) + + return { + "engineering_structures": structures, + "zouit_engineering_overlaps": zouit_overlaps, + "summary": { + "nearest_structure_distance_m": nearest_dist, + "in_protection_zone": protection_count > 0, + "protection_zones_intersecting": protection_count, + "total_structures_in_radius": len(structures), + }, + "dump_available": True, + "dump_fetched_at": dump_fetched_at, + } + + +def _get_parcel_wkt(db: Session, cad_num: str) -> str | None: + """Получить WKT геометрию участка из cad_parcels_geom или fallback источников. + + Игнорирует строки с geom IS NULL (data quality) — иначе ST_AsText(NULL) + вернёт SQL NULL → str(None) = "None" → ST_GeomFromText упадёт, ошибка + тихо проглотится в caller'е, клиент получит пустые массивы без причины. + """ + row = db.execute( + text( + """ + SELECT ST_AsText(geom) AS wkt + FROM cad_parcels_geom + WHERE cad_num = :c AND geom IS NOT NULL + LIMIT 1 + """ + ), + {"c": cad_num}, + ).first() + if row is not None and row[0] is not None: + return str(row[0]) + + # Fallback: кварталы (более крупный объект — менее точно, но лучше чем ничего). + # cad_quarters_geom хранит 3-сегментные ключи (66:41:0204016), не 4-сегментные. + quarter_key = derive_quarter_cad(cad_num) + row = db.execute( + text( + """ + SELECT ST_AsText(geom) AS wkt + FROM cad_quarters_geom + WHERE cad_number = :c AND geom IS NOT NULL + LIMIT 1 + """ + ), + {"c": quarter_key}, + ).first() + if row is not None and row[0] is not None: + return str(row[0]) + + return None + + +def _get_engineering_structures_by_boundary( + db: Session, + quarter: str, + parcel_wkt: str, + radius_m: int, +) -> list[dict[str, Any]]: + """Engineering structures из dump в radius_m от boundary участка. + + Использует ST_Distance к boundary (не centroid) для корректного расстояния. + Geometry трансформируется из EPSG:3857 (хранение dump) → 4326. + """ + try: + rows = db.execute( + text( + """ + SELECT feat.value->'properties' AS props, + feat.value->>'geometry' AS geom_json, + ST_Distance( + ST_Transform( + ST_SetSRID( + ST_GeomFromGeoJSON(feat.value->>'geometry'), + 3857 + ), + 4326 + )::geography, + ST_GeomFromText(:wkt, 4326)::geography + ) AS distance_m + FROM nspd_quarter_dumps d, + jsonb_array_elements(d.features_json) AS feat(value) + WHERE d.quarter_cad = :q + AND feat.value->>'layer' = 'engineering_structures' + AND (feat.value->'geometry') IS NOT NULL + AND feat.value->>'geometry' != 'null' + AND ST_DWithin( + ST_Transform( + ST_SetSRID( + ST_GeomFromGeoJSON(feat.value->>'geometry'), + 3857 + ), + 4326 + )::geography, + ST_GeomFromText(:wkt, 4326)::geography, + :radius_m + ) + ORDER BY distance_m ASC + LIMIT 50 + """ + ), + {"q": quarter, "wkt": parcel_wkt, "radius_m": radius_m}, + ).fetchall() + except (OperationalError, ProgrammingError) as e: + logger.warning( + "engineering_structures query failed for quarter=%s: %s", + quarter, + e, + ) + return [] + + result: list[dict[str, Any]] = [] + for r in rows: + props: dict[str, Any] = r[0] if isinstance(r[0], dict) else {} + geom_raw: str | None = r[1] + distance_m = float(r[2]) if r[2] is not None else 0.0 + + # Попытка распарсить geometry как dict для GeoJSON поля + geom_dict: dict[str, Any] = {} + if geom_raw: + try: + geom_dict = json.loads(geom_raw) + except (ValueError, json.JSONDecodeError): + geom_dict = {} + + result.append( + { + "name": props.get("name") or props.get("object_name"), + "type": props.get("purpose") or props.get("object_type") or props.get("type_zone"), + "cad_num": props.get("cad_num") or props.get("cadastral_number"), + "distance_to_boundary_m": round(distance_m, 1), + "geometry_geojson": geom_dict, + "readable_address": props.get("readable_address") or props.get("address"), + "raw_props": props, + "source": "nspd_36328", + } + ) + return result + + +def _get_zouit_engineering_overlaps( + db: Session, + quarter: str, + parcel_wkt: str, +) -> list[dict[str, Any]]: + """ZOUIT engineering (cat 37578) — охранные зоны, пересекающие участок. + + Использует слой 'zouit_engineering' из dump. + """ + try: + rows = db.execute( + text( + """ + SELECT feat.value->'properties' AS props, + feat.value->>'geometry' AS geom_json + FROM nspd_quarter_dumps d, + jsonb_array_elements(d.features_json) AS feat(value) + WHERE d.quarter_cad = :q + AND feat.value->>'layer' = 'zouit_engineering' + AND (feat.value->'geometry') IS NOT NULL + AND feat.value->>'geometry' != 'null' + AND ST_Intersects( + ST_Transform( + ST_SetSRID( + ST_GeomFromGeoJSON(feat.value->>'geometry'), + 3857 + ), + 4326 + ), + ST_GeomFromText(:wkt, 4326) + ) + LIMIT 100 + """ + ), + {"q": quarter, "wkt": parcel_wkt}, + ).fetchall() + except (OperationalError, ProgrammingError) as e: + logger.warning( + "zouit_engineering query failed for quarter=%s: %s", + quarter, + e, + ) + return [] + + result: list[dict[str, Any]] = [] + for r in rows: + props: dict[str, Any] = r[0] if isinstance(r[0], dict) else {} + geom_raw: str | None = r[1] + + geom_dict: dict[str, Any] = {} + if geom_raw: + try: + geom_dict = json.loads(geom_raw) + except (ValueError, json.JSONDecodeError): + geom_dict = {} + + subcategory_raw = props.get("subcategory") + subcategory: int | None = None + if subcategory_raw is not None: + try: + subcategory = int(subcategory_raw) + except (ValueError, TypeError): + subcategory = None + + result.append( + { + "reg_numb_border": props.get("reg_numb_border"), + "type_zone": props.get("type_zone") or props.get("zone_name"), + "subcategory": subcategory, + "intersects_parcel": True, + "geometry_geojson": geom_dict, + "raw_props": props, + "source": "nspd_37578", + } + ) + return result + + # ── Harvest trigger ─────────────────────────────────────────────────────────── diff --git a/backend/tests/api/v1/test_parcel_connection_points.py b/backend/tests/api/v1/test_parcel_connection_points.py new file mode 100644 index 00000000..a26fa16d --- /dev/null +++ b/backend/tests/api/v1/test_parcel_connection_points.py @@ -0,0 +1,267 @@ +"""Тесты для GET /{cad_num}/connection-points (issue #115). + +Использует FastAPI TestClient с mock DB — без реального Postgres. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +from app.main import app + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +_VALID_CAD = "66:41:0204016:10" +_QUARTER = "66:41:0204016" + + +def _mock_get_db(db: Any): + """FastAPI dependency override factory.""" + + def _get_db_override(): + yield db + + return _get_db_override + + +def _make_structure(distance_m: float = 120.5) -> dict[str, Any]: + return { + "name": "ТП-101", + "type": "Трансформаторная подстанция", + "cad_num": None, + "distance_to_boundary_m": distance_m, + "geometry_geojson": {"type": "Point", "coordinates": [60.6, 56.8]}, + "readable_address": None, + "raw_props": {"name": "ТП-101"}, + "source": "nspd_36328", + } + + +def _make_zouit_overlap() -> dict[str, Any]: + return { + "reg_numb_border": "RN-001", + "type_zone": "Охранная зона ЛЭП", + "subcategory": 5, + "intersects_parcel": True, + "geometry_geojson": {"type": "Polygon", "coordinates": [[]]}, + "raw_props": {"type_zone": "Охранная зона ЛЭП"}, + "source": "nspd_37578", + } + + +def _make_summary( + nearest: float | None = 120.5, + in_zone: bool = False, + zones_count: int = 0, + total: int = 1, +) -> dict[str, Any]: + return { + "nearest_structure_distance_m": nearest, + "in_protection_zone": in_zone, + "protection_zones_intersecting": zones_count, + "total_structures_in_radius": total, + } + + +def _make_full_response( + structures: list[dict[str, Any]] | None = None, + overlaps: list[dict[str, Any]] | None = None, + summary: dict[str, Any] | None = None, + dump_available: bool = True, + dump_fetched_at: str | None = "2026-05-01T12:00:00+00:00", +) -> dict[str, Any]: + if structures is None: + structures = [_make_structure()] + if overlaps is None: + overlaps = [] + if summary is None: + summary = _make_summary(total=len(structures)) + return { + "engineering_structures": structures, + "zouit_engineering_overlaps": overlaps, + "summary": summary, + "dump_available": dump_available, + "dump_fetched_at": dump_fetched_at, + } + + +# ── Tests ───────────────────────────────────────────────────────────────────── + + +def test_connection_points_no_dump_returns_empty() -> None: + """Квартал без dump → dump_available=false, пустые массивы, 200 OK.""" + from app.core.db import get_db + + db = MagicMock() + app.dependency_overrides[get_db] = _mock_get_db(db) + + empty_response = { + "engineering_structures": [], + "zouit_engineering_overlaps": [], + "summary": { + "nearest_structure_distance_m": None, + "in_protection_zone": False, + "protection_zones_intersecting": 0, + "total_structures_in_radius": 0, + }, + "dump_available": False, + "dump_fetched_at": None, + } + + try: + with patch( + "app.api.v1.parcels.get_connection_points", + return_value=empty_response, + ): + client = TestClient(app) + response = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-points") + + assert response.status_code == 200, response.text + body = response.json() + assert body["dump_available"] is False + assert body["engineering_structures"] == [] + assert body["zouit_engineering_overlaps"] == [] + assert body["summary"]["total_structures_in_radius"] == 0 + assert body["summary"]["nearest_structure_distance_m"] is None + finally: + app.dependency_overrides.clear() + + +def test_connection_points_parcel_not_found_404() -> None: + """cad_num не найден в БД (ValueError из сервиса) → 404.""" + from app.core.db import get_db + + db = MagicMock() + app.dependency_overrides[get_db] = _mock_get_db(db) + + try: + with patch( + "app.api.v1.parcels.get_connection_points", + side_effect=ValueError("Участок '66:41:9999999:1' не найден в БД"), + ): + client = TestClient(app) + response = client.get("/api/v1/parcels/66:41:9999999:1/connection-points") + + assert response.status_code == 404 + assert "не найден" in response.json()["detail"] + finally: + app.dependency_overrides.clear() + + +def test_connection_points_filters_by_radius() -> None: + """Фичи за radius_m не попадают в ответ (сервис фильтрует). + + Мокаем сервис: при radius_m=100 возвращаем только близкую структуру, + при radius_m=500 — обе. Проверяем что endpoint передаёт radius_m в сервис. + """ + from app.core.db import get_db + + db = MagicMock() + app.dependency_overrides[get_db] = _mock_get_db(db) + + close_only = _make_full_response(structures=[_make_structure(distance_m=80.0)]) + far_included = _make_full_response( + structures=[_make_structure(distance_m=80.0), _make_structure(distance_m=450.0)], + summary=_make_summary(nearest=80.0, total=2), + ) + + try: + with patch("app.api.v1.parcels.get_connection_points") as mock_svc: + mock_svc.return_value = close_only + client = TestClient(app) + r100 = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-points?radius_m=100") + assert r100.status_code == 200 + assert r100.json()["summary"]["total_structures_in_radius"] == 1 + # Проверяем что radius_m=100 передан в сервис + call_kwargs = mock_svc.call_args + assert call_kwargs[0][2] == 100 or call_kwargs[1].get("radius_m") == 100 + + mock_svc.return_value = far_included + r500 = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-points?radius_m=500") + assert r500.status_code == 200 + assert r500.json()["summary"]["total_structures_in_radius"] == 2 + finally: + app.dependency_overrides.clear() + + +def test_connection_points_zouit_intersects_flag() -> None: + """zouit_engineering_overlaps содержат intersects_parcel=true и правильные поля.""" + from app.core.db import get_db + + db = MagicMock() + app.dependency_overrides[get_db] = _mock_get_db(db) + + overlap = _make_zouit_overlap() + full = _make_full_response( + overlaps=[overlap], + summary=_make_summary(in_zone=True, zones_count=1), + ) + + try: + with patch( + "app.api.v1.parcels.get_connection_points", + return_value=full, + ): + client = TestClient(app) + response = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-points") + + assert response.status_code == 200 + body = response.json() + assert body["summary"]["in_protection_zone"] is True + assert body["summary"]["protection_zones_intersecting"] == 1 + overlaps = body["zouit_engineering_overlaps"] + assert len(overlaps) == 1 + assert overlaps[0]["intersects_parcel"] is True + assert overlaps[0]["reg_numb_border"] == "RN-001" + assert overlaps[0]["source"] == "nspd_37578" + finally: + app.dependency_overrides.clear() + + +def test_summary_nearest_distance() -> None: + """summary.nearest_structure_distance_m = расстояние до ближайшей структуры.""" + from app.core.db import get_db + + db = MagicMock() + app.dependency_overrides[get_db] = _mock_get_db(db) + + s1 = _make_structure(distance_m=42.7) + s2 = _make_structure(distance_m=180.0) + full = _make_full_response( + structures=[s1, s2], + summary=_make_summary(nearest=42.7, total=2), + ) + + try: + with patch( + "app.api.v1.parcels.get_connection_points", + return_value=full, + ): + client = TestClient(app) + response = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-points") + + assert response.status_code == 200 + body = response.json() + assert body["summary"]["nearest_structure_distance_m"] == pytest.approx(42.7) + assert body["summary"]["total_structures_in_radius"] == 2 + assert body["engineering_structures"][0]["distance_to_boundary_m"] == pytest.approx(42.7) + finally: + app.dependency_overrides.clear() + + +def test_connection_points_radius_out_of_range_422() -> None: + """radius_m=10 (< min 50) → 422 Unprocessable Entity.""" + client = TestClient(app) + response = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-points?radius_m=10") + assert response.status_code == 422 + + +def test_connection_points_radius_too_large_422() -> None: + """radius_m=5000 (> max 2000) → 422 Unprocessable Entity.""" + client = TestClient(app) + response = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-points?radius_m=5000") + assert response.status_code == 422 From e673811a20f37084f096bf5a819bd2110456def5 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 16 May 2026 07:52:34 +0000 Subject: [PATCH 005/342] feat(parcels): layout signature schemas + extractor (#113 PR A) (#193) --- backend/app/schemas/parcel.py | 81 ++++++++++++++++ .../services/site_finder/layout_signature.py | 58 +++++++++++ backend/tests/test_layout_signature.py | 97 +++++++++++++++++++ 3 files changed, 236 insertions(+) create mode 100644 backend/app/services/site_finder/layout_signature.py create mode 100644 backend/tests/test_layout_signature.py diff --git a/backend/app/schemas/parcel.py b/backend/app/schemas/parcel.py index c4e7e3ae..0a948e67 100644 --- a/backend/app/schemas/parcel.py +++ b/backend/app/schemas/parcel.py @@ -1,3 +1,4 @@ +import datetime as dt from typing import Any, Literal from pydantic import BaseModel, Field @@ -132,3 +133,83 @@ class CompetitorsSummary(BaseModel): class CompetitorsResponse(BaseModel): competitors: list[Competitor] summary: CompetitorsSummary + + +# ── Layout analysis (Issue #113) ─────────────────────────────────────────── + + +class LayoutSignature(BaseModel): + """Минимальная сигнатура планировки = (room_bucket, area_bin). + + Phase 2.1: layout_type/balcony_count в БД нет, ждут B2B Объектив (#52). + """ + + room_bucket: Literal["studio", "1", "2", "3", "4+"] + area_bin: Literal["<25", "25-40", "40-60", "60-80", "80-100", "100+"] + + +class BestLayoutsRequest(BaseModel): + """Параметры запроса top-планировок в радиусе вокруг участка.""" + + radius_km: float = Field(default=1.0, ge=0.1, le=1.5) + time_window: Literal["last_month", "last_quarter", "last_year"] = "last_quarter" + filter_competitor_obj_ids: list[int] | None = None + exclude_competitor_obj_ids: list[int] = Field(default_factory=list) + min_velocity_per_month: float = Field(default=0.5, ge=0.0, le=100.0) + obj_class_filter: Literal["economy", "comfort", "business"] | None = None + target_total_flats: int | None = Field(default=None, ge=1, le=10000) + + +class TopLayoutRow(BaseModel): + """Одна строка top-planirovok ranking'а.""" + + rank: int + room_bucket: str + area_bin: str + signature: str + competitor_obj_ids: list[int] + competitor_count: int + total_sold_in_window: int + velocity_per_month: float + avg_price_per_m2_rub: float | None # NULL если objective не покрывает obj + avg_area_m2: float + supply_units_in_radius: int + sold_pct_of_supply: float | None # NULL если supply=0 + + +class LayoutTzMixRow(BaseModel): + """Строка рекомендации unit-mix для ТЗ.""" + + room_bucket: str + pct: int # 0..100, sum total = 100 + abs_units: int | None # NULL если target_total_flats не задан + avg_target_area_m2: float | None + + +class LayoutTzRecommendation(BaseModel): + """Рекомендация для ТЗ на проектирование.""" + + rationale_text: str + mix: list[LayoutTzMixRow] + weighted_avg_price_per_m2_rub: float | None + based_on_obj_count: int + based_on_total_deals: int + data_window_start: dt.date + data_window_end: dt.date + + +class LayoutDataQuality(BaseModel): + """Метаданные качества данных (coverage).""" + + objects_with_velocity_data: int + objects_total_in_radius: int + velocity_coverage_pct: float + confidence: Literal["high", "medium", "low"] + + +class BestLayoutsResponse(BaseModel): + """Ответ /best-layouts endpoint'а.""" + + top_layouts: list[TopLayoutRow] + recommendation_for_tz: LayoutTzRecommendation + data_quality: LayoutDataQuality diff --git a/backend/app/services/site_finder/layout_signature.py b/backend/app/services/site_finder/layout_signature.py new file mode 100644 index 00000000..9fa628dd --- /dev/null +++ b/backend/app/services/site_finder/layout_signature.py @@ -0,0 +1,58 @@ +"""Layout signature extraction для Issue #113 (Phase 2.1 minimal). + +Без `layout_type` / `balcony_count` — ждут B2B Объектив (#52). +""" + +from __future__ import annotations + +from typing import Literal + +RoomBucket = Literal["studio", "1", "2", "3", "4+"] +AreaBin = Literal["<25", "25-40", "40-60", "60-80", "80-100", "100+"] + + +def room_bucket_from_flat( + rooms: int | None, + flat_type: str | None, + is_studio: bool | None, +) -> RoomBucket: + """Determine room_bucket из kn_flats полей. + + Priority: + 1. is_studio=True OR flat_type='Квартира-студия' → "studio" + 2. rooms IN (1, 2, 3) → str(rooms) + 3. rooms >= 4 → "4+" + 4. rooms = 0 (без is_studio) → "studio" + 5. fallback на "1" (rooms is None и не studio) + """ + if is_studio is True or flat_type == "Квартира-студия": + return "studio" + if rooms is None: + return "1" + if rooms == 0: + return "studio" + if rooms >= 4: + return "4+" + if rooms in (1, 2, 3): + return str(rooms) # type: ignore[return-value] + return "1" + + +def area_bin(area_m2: float) -> AreaBin: + """Bucket площади per ARN-buckets (НСПД).""" + if area_m2 < 25.0: + return "<25" + if area_m2 < 40.0: + return "25-40" + if area_m2 < 60.0: + return "40-60" + if area_m2 < 80.0: + return "60-80" + if area_m2 < 100.0: + return "80-100" + return "100+" + + +def layout_signature(room_bucket_val: RoomBucket, area_bin_val: AreaBin) -> str: + """Deterministic string-signature для группировки.""" + return f"{room_bucket_val}__{area_bin_val}" diff --git a/backend/tests/test_layout_signature.py b/backend/tests/test_layout_signature.py new file mode 100644 index 00000000..1fcad752 --- /dev/null +++ b/backend/tests/test_layout_signature.py @@ -0,0 +1,97 @@ +"""Тесты для layout_signature helpers (Issue #113, Phase 2.1). + +Pure-Python, без БД и external calls. +""" + +from __future__ import annotations + +import pytest + +from app.services.site_finder.layout_signature import ( + area_bin, + layout_signature, + room_bucket_from_flat, +) + + +@pytest.mark.parametrize( + "rooms, flat_type, is_studio, expected", + [ + (None, None, True, "studio"), + (0, "Квартира-студия", False, "studio"), + (0, "Квартира", None, "studio"), + (1, "Квартира", None, "1"), + (2, "Квартира", None, "2"), + (3, "Квартира", False, "3"), + (4, "Квартира", False, "4+"), + (5, "Квартира", None, "4+"), + (None, "Квартира", None, "1"), # fallback + ], +) +def test_room_bucket( + rooms: int | None, flat_type: str | None, is_studio: bool | None, expected: str +) -> None: + assert room_bucket_from_flat(rooms, flat_type, is_studio) == expected + + +@pytest.mark.parametrize( + "area, expected", + [ + (0.5, "<25"), + (24.9, "<25"), + (25.0, "25-40"), + (39.99, "25-40"), + (40.0, "40-60"), + (59.99, "40-60"), + (60.0, "60-80"), + (79.99, "60-80"), + (80.0, "80-100"), + (99.99, "80-100"), + (100.0, "100+"), + (250.0, "100+"), + ], +) +def test_area_bin(area: float, expected: str) -> None: + assert area_bin(area) == expected + + +def test_layout_signature_deterministic() -> None: + assert layout_signature("studio", "<25") == "studio__<25" + assert layout_signature("4+", "100+") == "4+__100+" + # Same input → same output + sig1 = layout_signature("2", "40-60") + sig2 = layout_signature("2", "40-60") + assert sig1 == sig2 + + +def test_room_bucket_is_studio_overrides_rooms() -> None: + """is_studio=True beats any rooms value.""" + assert room_bucket_from_flat(3, "Квартира", True) == "studio" + + +def test_room_bucket_flat_type_studio_overrides_rooms() -> None: + """flat_type='Квартира-студия' beats rooms=2.""" + assert room_bucket_from_flat(2, "Квартира-студия", None) == "studio" + + +def test_room_bucket_large_rooms() -> None: + """rooms=10 → '4+'.""" + assert room_bucket_from_flat(10, "Квартира", False) == "4+" + + +def test_area_bin_boundary_exact() -> None: + """Граничные значения попадают в правильный бакет (включение левой границы).""" + assert area_bin(25.0) == "25-40" + assert area_bin(40.0) == "40-60" + assert area_bin(60.0) == "60-80" + assert area_bin(80.0) == "80-100" + assert area_bin(100.0) == "100+" + + +def test_layout_signature_format() -> None: + """Сигнатура всегда содержит двойное подчёркивание-разделитель.""" + sig = layout_signature("1", "25-40") + assert "__" in sig + parts = sig.split("__") + assert parts[0] == "1" + assert parts[1] == "25-40" From 5455d57bed7f7a45dbc3cb406fceee3efe860b48 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 16 May 2026 08:26:04 +0000 Subject: [PATCH 006/342] chore(ci): run CI on push to feature branches (#195) --- .github/workflows/ci.yml | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 936cfd83..a2c3ed7e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,10 +2,23 @@ name: CI on: push: - branches: [main] + branches: + - main + - 'feat/**' + - 'fix/**' + - 'refactor/**' + - 'chore/**' + - 'docs/**' + - 'perf/**' + - 'test/**' + - 'hotfix/**' pull_request: branches: [main] +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: backend: runs-on: ubuntu-latest From 6aa22ef8ba74cda2fd89175245e04cd1df755ab3 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 16 May 2026 08:26:59 +0000 Subject: [PATCH 007/342] feat(analytics): mv_layout_velocity MV + refresh helper (#113 PR B) (#194) --- .../site_finder/layout_velocity_refresh.py | 57 ++++++++++++ data/sql/94_mv_layout_velocity.sql | 90 +++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 backend/app/services/site_finder/layout_velocity_refresh.py create mode 100644 data/sql/94_mv_layout_velocity.sql diff --git a/backend/app/services/site_finder/layout_velocity_refresh.py b/backend/app/services/site_finder/layout_velocity_refresh.py new file mode 100644 index 00000000..61075c60 --- /dev/null +++ b/backend/app/services/site_finder/layout_velocity_refresh.py @@ -0,0 +1,57 @@ +"""Refresh helper for mv_layout_velocity (Issue #113 PR B). + +Not scheduled automatically in this PR — intended for manual invocation or +a Celery beat task in a follow-up issue. + +Usage example (manual, via psql-connected shell or admin endpoint): + from sqlalchemy.orm import Session + from app.services.site_finder.layout_velocity_refresh import refresh_layout_velocity + + count = refresh_layout_velocity(db) + # logs: "mv_layout_velocity refreshed: 459 rows" +""" + +from __future__ import annotations + +import logging + +from sqlalchemy import text +from sqlalchemy.exc import OperationalError +from sqlalchemy.orm import Session + +logger = logging.getLogger(__name__) + + +def refresh_layout_velocity(db: Session, *, concurrently: bool = True) -> int: + """REFRESH MATERIALIZED VIEW mv_layout_velocity. + + Args: + db: SQLAlchemy Session (sync). + concurrently: When True, uses REFRESH CONCURRENTLY — non-blocking but + requires the unique index mv_layout_velocity_pk to exist (created + by 94_mv_layout_velocity.sql). Pass False only for the very first + populate or when the MV was just recreated. + + Returns: + Row count in the MV after refresh (for observability / alerting). + """ + try: + if concurrently: + db.execute(text("REFRESH MATERIALIZED VIEW CONCURRENTLY mv_layout_velocity")) + else: + db.execute(text("REFRESH MATERIALIZED VIEW mv_layout_velocity")) + db.commit() + except OperationalError as e: + if concurrently and "cannot refresh materialized view" in str(e).lower(): + logger.warning( + "CONCURRENTLY failed (MV likely not populated), falling back to non-concurrent" + ) + db.rollback() + db.execute(text("REFRESH MATERIALIZED VIEW mv_layout_velocity")) + db.commit() + else: + raise + row = db.execute(text("SELECT COUNT(*) FROM mv_layout_velocity")).first() + count = int(row[0]) if row else 0 + logger.info("mv_layout_velocity refreshed: %d rows", count) + return count diff --git a/data/sql/94_mv_layout_velocity.sql b/data/sql/94_mv_layout_velocity.sql new file mode 100644 index 00000000..d67301e5 --- /dev/null +++ b/data/sql/94_mv_layout_velocity.sql @@ -0,0 +1,90 @@ +-- 94_mv_layout_velocity.sql +-- Issue #113 PR B — Materialized view per (obj_id, room_bucket) for top-layouts ranking. +-- +-- Sources: +-- objective_corpus_room_month — monthly per-corpus-per-room deals (19 738 rows, 2025-05..2026-05) +-- objective_complex_mapping — project_name → domrf_obj_id mapping (114 mapped objects) +-- +-- Join key: +-- objective_corpus_room_month.project_name = objective_complex_mapping.objective_complex_name +-- (verified: one project_name → at most one domrf_obj_id) +-- +-- room_bucket normalisation: +-- cyrillic 'студия' → ASCII 'studio' for consistency with layout_signature.py (PR A, Issue #113) +-- values '1', '2', '3', '4+' are kept as-is +-- +-- Window: last 24 months (rolling, anchored to NOW() at refresh time) +-- Expected rows after aggregation: ~459 (obj_id × room_bucket combinations) +-- +-- REFRESH CONCURRENTLY is safe after this migration because a UNIQUE index +-- on (obj_id, room_bucket) is created immediately. +-- Subsequent refreshes: layout_velocity_refresh.py helper (PR B) — not scheduled yet. +-- +-- Deploy: auto-applied by deploy.yml via _schema_migrations tracking. +-- Dependencies: none (no views depend on this MV at creation time). +-- +-- WARN: Re-apply этого файла (DR / lost _schema_migrations entry / dev local) +-- снесёт MV + CASCADE удалит зависимости (e.g. future /best-layouts views). +-- После re-apply ПЕРВЫЙ refresh должен быть concurrently=False +-- (CONCURRENTLY требует уже populated MV — упадёт на пустой). +-- Tracking table `_schema_migrations` нормально предотвращает re-apply, +-- но добавлен comment как safety reminder. + +BEGIN; + +DROP MATERIALIZED VIEW IF EXISTS mv_layout_velocity CASCADE; + +CREATE MATERIALIZED VIEW mv_layout_velocity AS +WITH last24mo AS ( + SELECT + ocm.project_name, + CASE + WHEN ocm.room_bucket = 'студия' THEN 'studio' + ELSE ocm.room_bucket + END AS room_bucket, + ocm.deals_total_count, + ocm.deals_total_avg_area_m2, + ocm.deals_total_avg_price_thousand_rub_per_m2, + ocm.deals_total_vol_m2, + ocm.report_month + FROM objective_corpus_room_month ocm + WHERE ocm.report_month >= (NOW() - INTERVAL '24 months')::date +) +SELECT + cm.domrf_obj_id AS obj_id, + l.room_bucket, + SUM(l.deals_total_count)::int AS total_deals_24mo, + AVG(l.deals_total_avg_area_m2)::numeric(10, 2) AS avg_area_m2, + AVG(l.deals_total_avg_price_thousand_rub_per_m2)::numeric(12, 2) + AS avg_price_thousand_rub_per_m2, + SUM(l.deals_total_vol_m2)::numeric(12, 2) AS total_vol_m2, + MIN(l.report_month) AS window_start, + MAX(l.report_month) AS window_end, + COUNT(DISTINCT l.report_month)::int AS months_with_data +FROM last24mo l +JOIN objective_complex_mapping cm + ON cm.objective_complex_name = l.project_name +WHERE l.room_bucket IS NOT NULL + AND cm.domrf_obj_id IS NOT NULL + AND cm.objective_group = 'Екатеринбург' -- защита от cross-region Cartesian при future multi-city +GROUP BY cm.domrf_obj_id, l.room_bucket +WITH NO DATA; + +-- UNIQUE index required for REFRESH CONCURRENTLY (future periodic refreshes) +-- Created on empty MV → instant, no lock duration on data +CREATE UNIQUE INDEX mv_layout_velocity_pk + ON mv_layout_velocity (obj_id, room_bucket); + +-- Lookup index used by /best-layouts endpoint (PR C) +CREATE INDEX mv_layout_velocity_obj_idx + ON mv_layout_velocity (obj_id); + +-- Initial populate (non-concurrent — MV was just created, CONCURRENTLY not allowed on empty MV) +REFRESH MATERIALIZED VIEW mv_layout_velocity; + +COMMENT ON MATERIALIZED VIEW mv_layout_velocity IS + 'Per-(obj_id, room_bucket) deals aggregation за last 24 months. ' + 'Source: objective_corpus_room_month × objective_complex_mapping (EKB only). ' + 'Refresh via layout_velocity_refresh.py (concurrently=True after initial populate).'; + +COMMIT; From ad16fc0e42e27b3923d1a7af2746ede33166a44e Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 16 May 2026 09:10:06 +0000 Subject: [PATCH 008/342] feat(parcels): best-layouts endpoint + service (#113 PR C) (#196) --- backend/app/api/v1/parcels.py | 24 + .../app/services/site_finder/best_layouts.py | 583 ++++++++++++++++++ .../tests/api/v1/test_parcel_best_layouts.py | 379 ++++++++++++ 3 files changed, 986 insertions(+) create mode 100644 backend/app/services/site_finder/best_layouts.py create mode 100644 backend/tests/api/v1/test_parcel_best_layouts.py diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 9979df85..cf9d82dd 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -15,6 +15,8 @@ from sqlalchemy.orm import Session from app.core.config import settings from app.core.db import get_db from app.schemas.parcel import ( + BestLayoutsRequest, + BestLayoutsResponse, CompetitorsRequest, CompetitorsResponse, ConnectionPointsResponse, @@ -22,6 +24,7 @@ from app.schemas.parcel import ( ParcelSearchRequest, ParcelSearchResponse, ) +from app.services.site_finder.best_layouts import get_best_layouts from app.services.site_finder.cadastre_fetch import ( cad_exists_in_db, find_or_enqueue_fetch, @@ -2106,3 +2109,24 @@ async def get_parcel_competitors( status_code=500, detail="Ошибка расчёта конкурентов", ) from exc + + +@router.post("/{cad_num}/best-layouts", response_model=BestLayoutsResponse) +async def get_parcel_best_layouts( + cad_num: str, + body: BestLayoutsRequest, + db: Annotated[Session, Depends(get_db)], +) -> BestLayoutsResponse: + """Top layouts (rooms × area_bin) у конкурентов с ranking по velocity. + + Issue #113 Phase 2.1: "Анализ лучших планировок конкурентов → ТЗ на проектирование". + Reads from mv_layout_velocity (auto-populated via objective_corpus_room_month + × objective_complex_mapping). + """ + try: + return get_best_layouts(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("best_layouts endpoint failed for %s: %s", cad_num, exc) + raise HTTPException(status_code=500, detail="Internal server error") from exc diff --git a/backend/app/services/site_finder/best_layouts.py b/backend/app/services/site_finder/best_layouts.py new file mode 100644 index 00000000..ac3ca21c --- /dev/null +++ b/backend/app/services/site_finder/best_layouts.py @@ -0,0 +1,583 @@ +"""Анализ лучших планировок конкурентов по velocity (Issue #113 Phase 2.1). + +Источники: + cad_parcels_geom / cad_quarters_geom — центроид участка + domrf_kn_objects — ЖК в радиусе (latitude/longitude → geography) + mv_layout_velocity — (obj_id, room_bucket) → агрегат продаж 24 мес + domrf_kn_flats — supply count по (room_bucket, area_bin) + +Алгоритм: + Step 1: центроид участка (cad_parcels_geom → cad_quarters_geom fallback). + Step 2: obj_id конкурентов в радиусе (domrf_kn_objects + фильтры). + Step 3: JOIN mv_layout_velocity GROUP BY room_bucket. + Step 4: scale velocity по time_window. + Step 5: supply side из domrf_kn_flats — один батч-запрос. + Step 6: per-row signature + sold_pct. + Step 7: фильтр min_velocity + sort + rank. + Step 8: build recommendation_for_tz (unit-mix, price, rationale). + Step 9: data_quality (coverage + confidence). +""" + +from __future__ import annotations + +import datetime as dt +import logging +from typing import Any + +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.schemas.parcel import ( + BestLayoutsRequest, + BestLayoutsResponse, + LayoutDataQuality, + LayoutTzMixRow, + LayoutTzRecommendation, + TopLayoutRow, +) +from app.services.site_finder.layout_signature import area_bin, layout_signature + +logger = logging.getLogger(__name__) + +# Confidence thresholds (per coverage % of objects with MV velocity data) +# Tune via PR if business feedback требует. +LAYOUT_CONFIDENCE_HIGH_PCT = 50.0 +LAYOUT_CONFIDENCE_MEDIUM_PCT = 20.0 + +# Делители velocity: 24 мес → масштаб на указанный window +_VELOCITY_DIVISORS: dict[str, float] = { + "last_month": 24.0, + "last_quarter": 8.0, + "last_year": 2.0, +} + +# ── SQL: центроид участка ───────────────────────────────────────────────────── + +_PARCEL_CENTROID_SQL = text(""" + SELECT ST_X(pt) AS center_lon, + ST_Y(pt) AS center_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 +""") + +# ── SQL: obj_id конкурентов в радиусе ───────────────────────────────────────── +# Геометрия domrf_kn_objects вычисляется on-the-fly из (latitude, longitude) +# как ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)::geography +# (consistency с competitors.py). +# obj_class_filter: NULL = все классы. +# filter_competitor_obj_ids: NULL = не фильтровать по списку. + +_COMPETITORS_IN_RADIUS_SQL = text(""" + SELECT DISTINCT ON (obj_id) obj_id + FROM domrf_kn_objects + WHERE latitude IS NOT NULL AND longitude IS NOT NULL + AND ST_DWithin( + ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)::geography, + ST_SetSRID( + ST_MakePoint(CAST(:center_lon AS float), CAST(:center_lat AS float)), + 4326 + )::geography, + CAST(:radius_m AS float) + ) + AND ( + CAST(:obj_class_filter AS text) IS NULL + OR obj_class = CAST(:obj_class_filter AS text) + ) + ORDER BY obj_id, snapshot_date DESC NULLS LAST +""") + +# ── SQL: mv_layout_velocity GROUP BY room_bucket ───────────────────────────── + +_VELOCITY_BY_ROOM_SQL = text(""" + SELECT + room_bucket, + SUM(total_deals_24mo) AS sum_deals, + AVG(avg_area_m2) AS avg_area_m2, + AVG(avg_price_thousand_rub_per_m2) * 1000.0 AS avg_price_per_m2_rub, + array_agg(DISTINCT obj_id) AS competitor_obj_ids, + COUNT(DISTINCT obj_id) AS competitor_count, + MIN(window_start) AS window_start, + MAX(window_end) AS window_end + FROM mv_layout_velocity + WHERE obj_id = ANY(:obj_ids) + GROUP BY room_bucket +""") + +# ── SQL: supply по (room_bucket, area_bin) за последний снимок ─────────────── +# Один батч-запрос вместо N — возвращает map (rb, ab) → count. +# room_bucket и area_bin вычисляются в SQL аналогично layout_signature.py. + +_SUPPLY_BATCH_SQL = text(""" + SELECT + CASE + WHEN f.is_studio = TRUE OR f.flat_type = 'Квартира-студия' THEN 'studio' + WHEN f.rooms = 0 THEN 'studio' + WHEN f.rooms IN (1, 2, 3) THEN f.rooms::text + WHEN f.rooms >= 4 THEN '4+' + ELSE '1' + END AS rb, + CASE + WHEN f.total_area < 25 THEN '<25' + WHEN f.total_area < 40 THEN '25-40' + WHEN f.total_area < 60 THEN '40-60' + WHEN f.total_area < 80 THEN '60-80' + WHEN f.total_area < 100 THEN '80-100' + ELSE '100+' + END AS ab, + COUNT(*) AS units + FROM domrf_kn_flats f + JOIN domrf_kn_objects o ON f.obj_id = o.obj_id + WHERE o.latitude IS NOT NULL AND o.longitude IS NOT NULL + AND ST_DWithin( + 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, + CAST(:radius_m AS float) + ) + AND f.snapshot_date = CAST(:latest_snap AS date) + GROUP BY rb, ab +""") + + +# ── Вспомогательные функции ─────────────────────────────────────────────────── + + +def _quarter_from_cad(cad_num: str) -> str: + """Извлечь кадастровый квартал: '66:41:0303161:123' → '66:41:0303161'.""" + parts = cad_num.split(":") + if len(parts) >= 3: + return ":".join(parts[:3]) + return cad_num + + +def _normalize_pct(buckets: dict[str, float]) -> dict[str, int]: + """Нормировать доли до целых процентов с суммой ровно 100. + + Алгоритм largest-remainder (Hamilton method): + 1. Floor каждого значения. + 2. Остаток 100 − sum_floors распределить в top-bucket по дробной части. + """ + if not buckets: + return {} + + total = sum(buckets.values()) + if total <= 0: + n = len(buckets) + base = 100 // n + result = {k: base for k in buckets} + # распределить остаток + remainder = 100 - base * n + for k in list(buckets.keys())[:remainder]: + result[k] += 1 + return result + + raw = {k: v / total * 100.0 for k, v in buckets.items()} + floors = {k: int(v) for k, v in raw.items()} + remainder = 100 - sum(floors.values()) + # sort by fractional part desc + fracs = sorted(buckets.keys(), key=lambda k: -(raw[k] - floors[k])) + for k in fracs[:remainder]: + floors[k] += 1 + return floors + + +# ── Главная функция ─────────────────────────────────────────────────────────── + + +def get_best_layouts( + db: Session, + cad_num: str, + request: BestLayoutsRequest, +) -> BestLayoutsResponse: + """Top layouts (rooms × area_bin) конкурентов с рейтингом по velocity. + + Raises: + ValueError: если центроид участка не найден (caller → HTTP 404). + """ + quarter = _quarter_from_cad(cad_num) + radius_m = request.radius_km * 1000.0 + + # ── Step 1: центроид участка ───────────────────────────────────────────── + try: + coord_row = ( + db.execute( + _PARCEL_CENTROID_SQL, + {"cad_num": cad_num, "quarter": quarter}, + ) + .mappings() + .first() + ) + except Exception: + logger.exception("best_layouts: centroid query failed for cad_num=%s", cad_num) + raise + + if not coord_row: + raise ValueError(f"Геометрия для {cad_num} не найдена") + + center_lon = float(coord_row["center_lon"]) + center_lat = float(coord_row["center_lat"]) + + # ── Step 2: obj_id конкурентов в радиусе ──────────────────────────────── + try: + id_rows = ( + db.execute( + _COMPETITORS_IN_RADIUS_SQL, + { + "center_lon": center_lon, + "center_lat": center_lat, + "radius_m": radius_m, + "obj_class_filter": request.obj_class_filter, + }, + ) + .mappings() + .all() + ) + except Exception: + logger.exception("best_layouts: competitors-in-radius query failed for cad_num=%s", cad_num) + raise + + all_obj_ids: list[int] = [int(r["obj_id"]) for r in id_rows] + objects_total_in_radius = len(all_obj_ids) + + # Применить exclude / filter из request + exclude_set = set(request.exclude_competitor_obj_ids) + if exclude_set: + all_obj_ids = [oid for oid in all_obj_ids if oid not in exclude_set] + + if request.filter_competitor_obj_ids is not None: + filter_set = set(request.filter_competitor_obj_ids) + all_obj_ids = [oid for oid in all_obj_ids if oid in filter_set] + + if not all_obj_ids: + return _empty_response( + radius_km=request.radius_km, + time_window=request.time_window, + objects_total_in_radius=objects_total_in_radius, + ) + + # ── Step 3: mv_layout_velocity GROUP BY room_bucket ───────────────────── + try: + vel_rows = db.execute(_VELOCITY_BY_ROOM_SQL, {"obj_ids": all_obj_ids}).mappings().all() + except Exception: + logger.exception( + "best_layouts: velocity query failed for cad_num=%s obj_count=%d", + cad_num, + len(all_obj_ids), + ) + raise + + if not vel_rows: + return _empty_response( + radius_km=request.radius_km, + time_window=request.time_window, + objects_total_in_radius=objects_total_in_radius, + ) + + # ── Step 5: supply side (батч-запрос) ──────────────────────────────────── + # Pre-compute последний snapshot_date один раз — избегаем subquery на каждый scan. + latest_snap: dt.date | None = db.scalar(text("SELECT MAX(snapshot_date) FROM domrf_kn_flats")) + if latest_snap is None: + logger.warning("best_layouts: domrf_kn_flats пустой (нет snapshot_date), supply=0 fallback") + supply_rows = [] + else: + try: + supply_rows = ( + db.execute( + _SUPPLY_BATCH_SQL, + { + "center_lon": center_lon, + "center_lat": center_lat, + "radius_m": radius_m, + "latest_snap": latest_snap, + }, + ) + .mappings() + .all() + ) + except Exception: + logger.warning("best_layouts: supply query failed, supply=0 fallback") + supply_rows = [] + + supply_map: dict[tuple[str, str], int] = { + (str(r["rb"]), str(r["ab"])): int(r["units"]) for r in supply_rows + } + + # ── Step 4 + 6: scale velocity и enrichment per row ────────────────────── + divisor = _VELOCITY_DIVISORS[request.time_window] + + enriched: list[dict[str, Any]] = [] + window_start: dt.date | None = None + window_end: dt.date | None = None + + # Собираем obj_ids с данными в MV (для data_quality) + obj_ids_with_data: set[int] = set() + + for r in vel_rows: + room_bucket = str(r["room_bucket"]) + sum_deals = float(r["sum_deals"]) if r["sum_deals"] is not None else 0.0 + avg_area = float(r["avg_area_m2"]) if r["avg_area_m2"] is not None else 0.0 + price_rub = ( + float(r["avg_price_per_m2_rub"]) if r["avg_price_per_m2_rub"] is not None else None + ) + competitor_obj_ids: list[int] = ( + [int(oid) for oid in r["competitor_obj_ids"]] if r["competitor_obj_ids"] else [] + ) + competitor_count = int(r["competitor_count"]) + + obj_ids_with_data.update(competitor_obj_ids) + + # Step 4: scale + velocity_per_month = round(sum_deals / divisor, 2) + + # Step 6: area_bin по avg_area (layout_signature.area_bin) + ab = area_bin(avg_area) if avg_area > 0 else "<25" + sig = layout_signature(room_bucket, ab) # type: ignore[arg-type] + + supply_count = supply_map.get((room_bucket, ab), 0) + sold_pct: float | None = None + if supply_count > 0: + sold_pct = round(sum_deals / supply_count * 100.0, 1) + + # data window + if r["window_start"] is not None: + ws = r["window_start"] + if isinstance(ws, str): + ws = dt.date.fromisoformat(ws) + elif isinstance(ws, dt.datetime): + ws = ws.date() + window_start = ws if window_start is None else min(window_start, ws) + + if r["window_end"] is not None: + we = r["window_end"] + if isinstance(we, str): + we = dt.date.fromisoformat(we) + elif isinstance(we, dt.datetime): + we = we.date() + window_end = we if window_end is None else max(window_end, we) + + enriched.append( + { + "room_bucket": room_bucket, + "area_bin": ab, + "signature": sig, + "competitor_obj_ids": competitor_obj_ids, + "competitor_count": competitor_count, + "sum_deals": sum_deals, + "velocity_per_month": velocity_per_month, + "avg_price_per_m2_rub": price_rub, + "avg_area_m2": avg_area, + "supply_units_in_radius": supply_count, + "sold_pct_of_supply": sold_pct, + } + ) + + # ── Step 7: фильтр min_velocity + sort + rank ──────────────────────────── + filtered = [ + row for row in enriched if row["velocity_per_month"] >= request.min_velocity_per_month + ] + filtered.sort(key=lambda r: r["velocity_per_month"], reverse=True) + + top_layouts: list[TopLayoutRow] = [] + for rank_idx, row in enumerate(filtered, start=1): + top_layouts.append( + TopLayoutRow( + rank=rank_idx, + room_bucket=row["room_bucket"], + area_bin=row["area_bin"], + signature=row["signature"], + competitor_obj_ids=row["competitor_obj_ids"], + competitor_count=row["competitor_count"], + total_sold_in_window=int(row["sum_deals"]), + velocity_per_month=row["velocity_per_month"], + avg_price_per_m2_rub=row["avg_price_per_m2_rub"], + avg_area_m2=round(row["avg_area_m2"], 1), + supply_units_in_radius=row["supply_units_in_radius"], + sold_pct_of_supply=row["sold_pct_of_supply"], + ) + ) + + # ── Step 8: build recommendation_for_tz ───────────────────────────────── + # Используем filtered (только > min_velocity) для recommendation. + # Если после фильтрации всё пустое — используем enriched (все данные без фильтра). + rec_source = filtered if filtered else enriched + + today = dt.date.today() + ws_date = window_start if window_start is not None else today + we_date = window_end if window_end is not None else today + + recommendation = _build_recommendation( + rows=rec_source, + radius_km=request.radius_km, + time_window=request.time_window, + target_total_flats=request.target_total_flats, + window_start=ws_date, + window_end=we_date, + all_enriched=enriched, + ) + + # ── Step 9: data_quality ───────────────────────────────────────────────── + # Denominator = post-filter set (effective consideration set после exclude/filter). + objects_total_after_filter = len(all_obj_ids) + objects_with_data = len(obj_ids_with_data & set(all_obj_ids)) + coverage_pct = ( + round(objects_with_data / objects_total_after_filter * 100.0, 1) + if objects_total_after_filter > 0 + else 0.0 + ) + if coverage_pct >= LAYOUT_CONFIDENCE_HIGH_PCT: + confidence: str = "high" + elif coverage_pct >= LAYOUT_CONFIDENCE_MEDIUM_PCT: + confidence = "medium" + else: + confidence = "low" + + data_quality = LayoutDataQuality( + objects_with_velocity_data=objects_with_data, + objects_total_in_radius=objects_total_after_filter, + velocity_coverage_pct=coverage_pct, + confidence=confidence, # type: ignore[arg-type] + ) + + return BestLayoutsResponse( + top_layouts=top_layouts, + recommendation_for_tz=recommendation, + data_quality=data_quality, + ) + + +def _build_recommendation( + rows: list[dict[str, Any]], + radius_km: float, + time_window: str, + target_total_flats: int | None, + window_start: dt.date, + window_end: dt.date, + all_enriched: list[dict[str, Any]], +) -> LayoutTzRecommendation: + """Собрать LayoutTzRecommendation из enriched rows.""" + if not rows: + return LayoutTzRecommendation( + rationale_text=( + f"В радиусе {radius_km}км: нет layout-паттернов с достаточной velocity." + ), + mix=[], + weighted_avg_price_per_m2_rub=None, + based_on_obj_count=0, + based_on_total_deals=0, + data_window_start=window_start, + data_window_end=window_end, + ) + + # Группировка по room_bucket (строки уже могут быть per-bucket из MV GROUP BY) + rb_deals: dict[str, float] = {} + rb_area_weighted: dict[str, float] = {} + rb_price_weighted: dict[str, float] = {} + rb_price_total_deals: dict[str, float] = {} + all_competitor_ids: set[int] = set() + + for row in rows: + rb = row["room_bucket"] + sd = float(row["sum_deals"]) + rb_deals[rb] = rb_deals.get(rb, 0.0) + sd + rb_area_weighted[rb] = rb_area_weighted.get(rb, 0.0) + row["avg_area_m2"] * sd + all_competitor_ids.update(row["competitor_obj_ids"]) + if row["avg_price_per_m2_rub"] is not None: + rb_price_weighted[rb] = rb_price_weighted.get(rb, 0.0) + ( + row["avg_price_per_m2_rub"] * sd + ) + rb_price_total_deals[rb] = rb_price_total_deals.get(rb, 0.0) + sd + + total_deals = sum(rb_deals.values()) + pct_map = _normalize_pct(rb_deals) + + mix: list[LayoutTzMixRow] = [] + for rb, pct in sorted(pct_map.items(), key=lambda x: -x[1]): + avg_area = ( + round(rb_area_weighted[rb] / rb_deals[rb], 1) if rb_deals.get(rb, 0) > 0 else None + ) + abs_units: int | None = None + if target_total_flats is not None: + abs_units = round(pct / 100.0 * target_total_flats) + mix.append( + LayoutTzMixRow( + room_bucket=rb, + pct=pct, + abs_units=abs_units, + avg_target_area_m2=avg_area, + ) + ) + + # Weighted avg price across all room_buckets + total_price_deals = sum(rb_price_total_deals.values()) + weighted_price: float | None = None + if total_price_deals > 0: + weighted_price = round(sum(rb_price_weighted.values()) / total_price_deals, 0) + + # Rationale + competitor_count = len(all_competitor_ids) + tw_label = {"last_month": "1 мес", "last_quarter": "квартал", "last_year": "год"}.get( + time_window, time_window + ) + rationale_text = ( + f"В радиусе {radius_km}км за {tw_label}: " + f"{len(rows)} активных layout-паттернов, " + f"total {int(total_deals)} продаж в {competitor_count} ЖК" + ) + + # based_on_obj_count из all_enriched (уникальные obj_id с данными MV) + all_mv_obj_ids: set[int] = set() + for row in all_enriched: + all_mv_obj_ids.update(row["competitor_obj_ids"]) + + return LayoutTzRecommendation( + rationale_text=rationale_text, + mix=mix, + weighted_avg_price_per_m2_rub=weighted_price, + based_on_obj_count=len(all_mv_obj_ids), + based_on_total_deals=int(total_deals), + data_window_start=window_start, + data_window_end=window_end, + ) + + +def _empty_response( + radius_km: float, + time_window: str, + objects_total_in_radius: int, +) -> BestLayoutsResponse: + """Ответ когда нет конкурентов или нет MV данных.""" + today = dt.date.today() + tw_label = {"last_month": "1 мес", "last_quarter": "квартал", "last_year": "год"}.get( + time_window, time_window + ) + return BestLayoutsResponse( + top_layouts=[], + recommendation_for_tz=LayoutTzRecommendation( + rationale_text=( + f"В радиусе {radius_km}км за {tw_label}: " + f"конкуренты не найдены или нет данных velocity." + ), + mix=[], + weighted_avg_price_per_m2_rub=None, + based_on_obj_count=0, + based_on_total_deals=0, + data_window_start=today, + data_window_end=today, + ), + data_quality=LayoutDataQuality( + objects_with_velocity_data=0, + objects_total_in_radius=objects_total_in_radius, + velocity_coverage_pct=0.0, + confidence="low", + ), + ) diff --git a/backend/tests/api/v1/test_parcel_best_layouts.py b/backend/tests/api/v1/test_parcel_best_layouts.py new file mode 100644 index 00000000..8b391f90 --- /dev/null +++ b/backend/tests/api/v1/test_parcel_best_layouts.py @@ -0,0 +1,379 @@ +"""Тесты для POST /api/v1/parcels/{cad_num}/best-layouts (Issue #113 Phase 2.1). + +Mock-based — не требуют живой БД. +Паттерн mock DB: аналогично test_parcel_competitors.py — dependency_overrides[get_db]. + +Порядок вызовов в get_best_layouts: + db.scalar() → MAX(snapshot_date) (только когда vel_rows non-empty) + db.execute() calls: + 1. _PARCEL_CENTROID_SQL → .mappings().first() + 2. _COMPETITORS_IN_RADIUS_SQL → .mappings().all() + 3. _VELOCITY_BY_ROOM_SQL → .mappings().all() + 4. _SUPPLY_BATCH_SQL → .mappings().all() (пропускается если latest_snap is None) +""" + +from __future__ import annotations + +import datetime as dt +from unittest.mock import MagicMock + +import pytest +from fastapi.testclient import TestClient + +from app.main import app + +# ── Фабрики mock-строк ──────────────────────────────────────────────────────── + +CAD_NUM = "66:41:0303161:123" +_TODAY = dt.date.today() + + +def _coord_row(lon: float = 60.6, lat: float = 56.85) -> MagicMock: + """Центроид участка (EPSG:4326 lon/lat).""" + r = MagicMock() + r.__getitem__ = lambda self, k: {"center_lon": lon, "center_lat": lat}[k] + return r + + +def _obj_id_row(obj_id: int) -> MagicMock: + """Строка obj_id из _COMPETITORS_IN_RADIUS_SQL.""" + r = MagicMock() + r.__getitem__ = lambda self, k: {"obj_id": obj_id}[k] + return r + + +def _vel_row( + room_bucket: str = "2", + sum_deals: float = 48.0, + avg_area: float = 55.0, + avg_price_rub: float | None = 120000.0, + obj_ids: list[int] | None = None, + window_start: dt.date | None = None, + window_end: dt.date | None = None, +) -> MagicMock: + """Строка из mv_layout_velocity GROUP BY room_bucket.""" + oids = obj_ids if obj_ids is not None else [1] + ws = window_start or _TODAY - dt.timedelta(days=730) + we = window_end or _TODAY + + r = MagicMock() + r.__getitem__ = lambda self, k: { + "room_bucket": room_bucket, + "sum_deals": sum_deals, + "avg_area_m2": avg_area, + "avg_price_per_m2_rub": avg_price_rub, + "competitor_obj_ids": oids, + "competitor_count": len(oids), + "window_start": ws, + "window_end": we, + }[k] + return r + + +def _supply_row(rb: str, ab: str, units: int) -> MagicMock: + """Строка из _SUPPLY_BATCH_SQL.""" + r = MagicMock() + r.__getitem__ = lambda self, k: {"rb": rb, "ab": ab, "units": units}[k] + return r + + +# ── Построение mock DB ──────────────────────────────────────────────────────── + + +def _make_db( + coord: MagicMock | None = None, + id_rows: list[MagicMock] | None = None, + vel_rows: list[MagicMock] | None = None, + supply_rows: list[MagicMock] | None = None, + latest_snap: dt.date | None = None, +) -> MagicMock: + """Сконструировать mock Session. + + db.scalar() возвращает latest_snap (MAX snapshot_date) — вызывается перед supply. + Порядок db.execute(): + 1. centroid → .mappings().first() + 2. competitors-in-radius → .mappings().all() + 3. velocity → .mappings().all() + 4. supply → .mappings().all() (только если latest_snap is not None) + """ + db = MagicMock() + + # db.scalar — pre-computed MAX(snapshot_date) для supply query + db.scalar.return_value = latest_snap if latest_snap is not None else _TODAY + + results: list[MagicMock] = [] + + # 1: centroid + r0 = MagicMock() + r0.mappings.return_value.first.return_value = coord + results.append(r0) + + # 2: competitors-in-radius + r1 = MagicMock() + r1.mappings.return_value.all.return_value = id_rows or [] + results.append(r1) + + # 3: velocity (only queried if id_rows non-empty) + r2 = MagicMock() + r2.mappings.return_value.all.return_value = vel_rows or [] + results.append(r2) + + # 4: supply + r3 = MagicMock() + r3.mappings.return_value.all.return_value = supply_rows or [] + results.append(r3) + + db.execute.side_effect = results + return db + + +def _override_db(db: MagicMock): + def _get_db_override(): + yield db + + return _get_db_override + + +def _post(client: TestClient, cad: str = CAD_NUM, **body_kwargs) -> dict: + payload = {"radius_km": 1.0, "time_window": "last_quarter", **body_kwargs} + resp = client.post(f"/api/v1/parcels/{cad}/best-layouts", json=payload) + return resp + + +# ── Тесты ───────────────────────────────────────────────────────────────────── + + +def test_parcel_not_found_404() -> None: + """Если центроид не найден → 404.""" + db = _make_db(coord=None) + from app.core.db import get_db + + app.dependency_overrides[get_db] = _override_db(db) + try: + resp = _post(TestClient(app), cad="99:99:9999999:999") + assert resp.status_code == 404, resp.text + finally: + app.dependency_overrides.clear() + + +def test_empty_competitor_set_returns_low_confidence() -> None: + """Нет конкурентов в радиусе → пустые top_layouts + confidence=low.""" + db = _make_db(coord=_coord_row(), id_rows=[]) + from app.core.db import get_db + + app.dependency_overrides[get_db] = _override_db(db) + try: + resp = _post(TestClient(app)) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["top_layouts"] == [] + assert body["data_quality"]["confidence"] == "low" + assert body["data_quality"]["objects_total_in_radius"] == 0 + rec = body["recommendation_for_tz"] + assert rec["based_on_obj_count"] == 0 + assert rec["based_on_total_deals"] == 0 + assert rec["mix"] == [] + finally: + app.dependency_overrides.clear() + + +def test_three_obj_ids_ranking_and_pct_sum_100() -> None: + """3 obj_id, 3 room_buckets — ranking по velocity, sum pct = 100.""" + id_rows = [_obj_id_row(1), _obj_id_row(2), _obj_id_row(3)] + vel_rows = [ + _vel_row("studio", sum_deals=8.0, avg_area=26.0, obj_ids=[1]), + _vel_row("1", sum_deals=32.0, avg_area=40.0, obj_ids=[2]), + _vel_row("2", sum_deals=48.0, avg_area=55.0, obj_ids=[3]), + ] + supply_rows = [ + _supply_row("studio", "25-40", 20), + _supply_row("1", "40-60", 60), + _supply_row("2", "40-60", 80), + ] + db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows, supply_rows=supply_rows) + from app.core.db import get_db + + app.dependency_overrides[get_db] = _override_db(db) + try: + resp = _post(TestClient(app), time_window="last_quarter") + assert resp.status_code == 200, resp.text + body = resp.json() + top = body["top_layouts"] + assert len(top) == 3 + # rank 1 = самая высокая velocity (2-комн: 48/8=6.0 per month) + assert top[0]["rank"] == 1 + assert top[0]["room_bucket"] == "2" + # все ранги уникальны + assert sorted(t["rank"] for t in top) == [1, 2, 3] + # sum pct = 100 + mix = body["recommendation_for_tz"]["mix"] + assert sum(m["pct"] for m in mix) == 100 + finally: + app.dependency_overrides.clear() + + +def test_exclude_competitor_obj_ids_filter() -> None: + """exclude_competitor_obj_ids исключает obj_id: при all excluded → пустой ответ.""" + # Если после исключения obj_id_list пуст → _empty_response → top_layouts=[] + id_rows = [_obj_id_row(20)] # единственный конкурент + db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=[]) + from app.core.db import get_db + + app.dependency_overrides[get_db] = _override_db(db) + try: + resp = _post(TestClient(app), exclude_competitor_obj_ids=[20]) + assert resp.status_code == 200, resp.text + body = resp.json() + # После исключения obj_id=20 список пуст → пустой ответ + assert body["top_layouts"] == [] + assert body["data_quality"]["confidence"] == "low" + # objects_total_in_radius = 1 (до исключения) + assert body["data_quality"]["objects_total_in_radius"] == 1 + finally: + app.dependency_overrides.clear() + + +def test_min_velocity_per_month_filters_low_rows() -> None: + """min_velocity_per_month=5 → строки с velocity<5 не попадают в top_layouts.""" + id_rows = [_obj_id_row(1), _obj_id_row(2)] + # last_quarter divisor=8 → 16/8=2.0 (ниже порога), 80/8=10.0 (выше) + vel_rows = [ + _vel_row("studio", sum_deals=16.0, obj_ids=[1]), + _vel_row("1", sum_deals=80.0, obj_ids=[2]), + ] + db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows) + from app.core.db import get_db + + app.dependency_overrides[get_db] = _override_db(db) + try: + resp = _post(TestClient(app), min_velocity_per_month=5.0) + assert resp.status_code == 200, resp.text + body = resp.json() + top = body["top_layouts"] + assert len(top) == 1 + assert top[0]["room_bucket"] == "1" + assert top[0]["velocity_per_month"] == pytest.approx(10.0) + finally: + app.dependency_overrides.clear() + + +def test_time_window_velocity_scaling() -> None: + """last_month vs last_year дают разный velocity_per_month для одних deals.""" + # sum_deals=24 → last_month: 24/24=1.0, last_year: 24/2=12.0 + id_rows = [_obj_id_row(1)] + vel_rows_fixed = [_vel_row("2", sum_deals=24.0, obj_ids=[1])] + + from app.core.db import get_db + + # last_month + db_m = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows_fixed) + app.dependency_overrides[get_db] = _override_db(db_m) + try: + resp_m = _post(TestClient(app), time_window="last_month") + assert resp_m.status_code == 200, resp_m.text + v_month = resp_m.json()["top_layouts"][0]["velocity_per_month"] + finally: + app.dependency_overrides.clear() + + # last_year + db_y = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows_fixed) + app.dependency_overrides[get_db] = _override_db(db_y) + try: + resp_y = _post(TestClient(app), time_window="last_year") + assert resp_y.status_code == 200, resp_y.text + v_year = resp_y.json()["top_layouts"][0]["velocity_per_month"] + finally: + app.dependency_overrides.clear() + + # last_year velocity должна быть выше (делитель меньше: 2 vs 24) + assert v_year > v_month + assert v_month == pytest.approx(1.0) + assert v_year == pytest.approx(12.0) + + +def test_obj_class_filter_passes_through() -> None: + """obj_class_filter передаётся в SQL — endpoint не ломается, возвращает 200.""" + db = _make_db( + coord=_coord_row(), + id_rows=[_obj_id_row(5)], + vel_rows=[_vel_row("2", obj_ids=[5])], + supply_rows=[], + ) + from app.core.db import get_db + + app.dependency_overrides[get_db] = _override_db(db) + try: + resp = _post(TestClient(app), obj_class_filter="comfort") + assert resp.status_code == 200, resp.text + body = resp.json() + assert len(body["top_layouts"]) > 0 + finally: + app.dependency_overrides.clear() + + +def test_mv_empty_for_competitors_returns_empty_top_layouts() -> None: + """Конкуренты есть в радиусе, но MV пустой → top_layouts=[], data_quality.confidence=low.""" + id_rows = [_obj_id_row(1), _obj_id_row(2)] + db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=[]) + from app.core.db import get_db + + app.dependency_overrides[get_db] = _override_db(db) + try: + resp = _post(TestClient(app)) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["top_layouts"] == [] + dq = body["data_quality"] + assert dq["objects_total_in_radius"] == 2 + assert dq["objects_with_velocity_data"] == 0 + assert dq["confidence"] == "low" + finally: + app.dependency_overrides.clear() + + +def test_target_total_flats_fills_abs_units() -> None: + """target_total_flats=100 → abs_units заполнен в mix, sum примерно = 100.""" + id_rows = [_obj_id_row(1), _obj_id_row(2)] + vel_rows = [ + _vel_row("1", sum_deals=60.0, obj_ids=[1]), + _vel_row("2", sum_deals=40.0, obj_ids=[2]), + ] + db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows) + from app.core.db import get_db + + app.dependency_overrides[get_db] = _override_db(db) + try: + resp = _post(TestClient(app), target_total_flats=100) + assert resp.status_code == 200, resp.text + mix = resp.json()["recommendation_for_tz"]["mix"] + # все abs_units заполнены + for m in mix: + assert m["abs_units"] is not None + # сумма abs_units близка к 100 (round-off ±1) + total_abs = sum(m["abs_units"] for m in mix) + assert 98 <= total_abs <= 102 + finally: + app.dependency_overrides.clear() + + +def test_filter_competitor_obj_ids_applied() -> None: + """filter_competitor_obj_ids=[1] оставляет только obj_id=1.""" + id_rows = [_obj_id_row(1), _obj_id_row(2), _obj_id_row(3)] + # После фильтрации остаётся только obj_id=1, velocity запрос получит [1] + vel_rows = [_vel_row("2", sum_deals=24.0, obj_ids=[1])] + db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows) + from app.core.db import get_db + + app.dependency_overrides[get_db] = _override_db(db) + try: + resp = _post(TestClient(app), filter_competitor_obj_ids=[1]) + assert resp.status_code == 200, resp.text + body = resp.json() + top = body["top_layouts"] + assert len(top) >= 1 + # competitor_obj_ids должен содержать только 1 + for row in top: + for oid in row["competitor_obj_ids"]: + assert oid == 1 + finally: + app.dependency_overrides.clear() From 256909d28b6452e57a4707f64bb8410fe22b0b37 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 16 May 2026 09:53:05 +0000 Subject: [PATCH 009/342] =?UTF-8?q?feat(layouts):=20PDF=20=D0=A2=D0=97=20e?= =?UTF-8?q?ndpoint=20+=20BestLayoutsBlock=20UI=20(#113=20PR=20D)=20(#199)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/v1/parcels.py | 36 + .../app/services/exporters/layout_tz_pdf.py | 161 ++++ backend/tests/test_layout_tz_pdf.py | 136 ++++ .../site-finder/BestLayoutsBlock.tsx | 761 ++++++++++++++++++ .../src/components/site-finder/MarketTab.tsx | 4 + frontend/src/hooks/useBestLayouts.ts | 29 + frontend/src/types/best-layouts.ts | 63 ++ 7 files changed, 1190 insertions(+) create mode 100644 backend/app/services/exporters/layout_tz_pdf.py create mode 100644 backend/tests/test_layout_tz_pdf.py create mode 100644 frontend/src/components/site-finder/BestLayoutsBlock.tsx create mode 100644 frontend/src/hooks/useBestLayouts.ts create mode 100644 frontend/src/types/best-layouts.ts diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index cf9d82dd..075983ab 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -24,6 +24,7 @@ from app.schemas.parcel import ( ParcelSearchRequest, ParcelSearchResponse, ) +from app.services.exporters.layout_tz_pdf import render_layout_tz_pdf from app.services.site_finder.best_layouts import get_best_layouts from app.services.site_finder.cadastre_fetch import ( cad_exists_in_db, @@ -2130,3 +2131,38 @@ async def get_parcel_best_layouts( except Exception as exc: logger.error("best_layouts endpoint failed for %s: %s", cad_num, exc) raise HTTPException(status_code=500, detail="Internal server error") from exc + + +@router.post("/{cad_num}/best-layouts/pdf") +async def get_parcel_best_layouts_pdf( + cad_num: str, + body: BestLayoutsRequest, + db: Annotated[Session, Depends(get_db)], +) -> Response: + """ТЗ на проектирование (PDF) — генерируется из /best-layouts данных. + + Issue #113 Phase 2.1: data-driven unit-mix recommendation для тендера. + """ + try: + response = get_best_layouts(db=db, cad_num=cad_num, request=body) + pdf_bytes = render_layout_tz_pdf( + response, + cad_num=cad_num, + radius_km=body.radius_km, + time_window=body.time_window, + ) + today = _dt.date.today().strftime("%Y-%m-%d") + cad_safe = cad_num.replace(":", "-") + filename = f"tz-layout-{cad_safe}-{today}.pdf" + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + except HTTPException: + raise + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except Exception as exc: + logger.error("best_layouts PDF endpoint failed for %s: %s", cad_num, exc) + raise HTTPException(status_code=500, detail="Internal server error") from exc diff --git a/backend/app/services/exporters/layout_tz_pdf.py b/backend/app/services/exporters/layout_tz_pdf.py new file mode 100644 index 00000000..96178212 --- /dev/null +++ b/backend/app/services/exporters/layout_tz_pdf.py @@ -0,0 +1,161 @@ +"""PDF render для ТЗ (Layout Analysis #113 PR D). + +Pattern reference: backend/app/services/exporters/pdf.py (existing WeasyPrint). +""" + +from __future__ import annotations + +import datetime as dt +import html as _html +import logging + +from weasyprint import HTML + +from app.schemas.parcel import BestLayoutsResponse + +logger = logging.getLogger(__name__) + + +def render_layout_tz_pdf( + response: BestLayoutsResponse, + *, + cad_num: str, + parcel_address: str | None = None, + radius_km: float, + time_window: str, +) -> bytes: + """Render ТЗ PDF от best-layouts response. + + Args: + response: BestLayoutsResponse от /best-layouts endpoint + cad_num: кадастровый номер участка + parcel_address: optional human address (если known через geocoder) + radius_km: радиус анализа конкурентов + time_window: окно анализа (last_month/quarter/year) + + Returns: + PDF bytes (готово для StreamingResponse) + """ + today = dt.date.today().strftime("%d.%m.%Y") + safe_cad = _html.escape(cad_num) + safe_addr = _html.escape(parcel_address) if parcel_address else None + safe_time_window = _html.escape(time_window) + addr_line = f"

Адрес: {safe_addr}

" if safe_addr else "" + + def _price_cell(val: float | None) -> str: + if val is None: + return "—" + return f"{val:,.0f}".replace(",", " ") + " ₽" + + # Top layouts table rows + top_rows = "".join( + "" + f"{r.rank}" + f"{_html.escape(r.room_bucket)}" + f"{_html.escape(r.area_bin)}" + f"{r.velocity_per_month:.1f}" + f"{r.avg_area_m2:.1f}" + f"{_price_cell(r.avg_price_per_m2_rub)}" + f"{r.total_sold_in_window}" + "" + for r in response.top_layouts + ) + + # Recommendation mix table rows + mix_rows = "".join( + "" + f"{_html.escape(m.room_bucket)}" + f"{m.pct}%" + f"{m.abs_units if m.abs_units is not None else '—'}" + f"{f'{m.avg_target_area_m2:.1f}' if m.avg_target_area_m2 is not None else '—'}" + "" + for m in response.recommendation_for_tz.mix + ) + + rec = response.recommendation_for_tz + safe_rationale = _html.escape(rec.rationale_text) + weighted_price = ( + f"{rec.weighted_avg_price_per_m2_rub:,.0f}".replace(",", " ") + " ₽/м²" + if rec.weighted_avg_price_per_m2_rub is not None + else "нет данных" + ) + + dq = response.data_quality + + html = f""" + + + +ТЗ на проектирование — {safe_cad} + + + +

Техническое задание на проектирование (data-driven)

+
+

Кадастровый номер: {safe_cad}

+ {addr_line} +

Радиус анализа: {radius_km} км · Окно: {safe_time_window}

+

Дата формирования: {today}

+
+ +

Рекомендуемая структура квартирографии (unit-mix)

+
{safe_rationale}
+ + + + + {mix_rows} +
КомнатностьДоляКол-во (от target)Целевая площадь, м²
+

Средневзвешенная цена benchmark: {weighted_price}

+

Основано на {rec.based_on_obj_count} ЖК / {rec.based_on_total_deals} сделок

+

Период данных: + {rec.data_window_start.strftime("%d.%m.%Y")} – {rec.data_window_end.strftime("%d.%m.%Y")} +

+ +

Топ планировок конкурентов по продажам

+ + + + + + {top_rows} +
#КомнатыПлощадьПродажи/месСр. площадь, м²Ср. цена, ₽/м²Продано (окно)
+ +

Качество данных

+

+ Покрытие: {dq.objects_with_velocity_data} из + {dq.objects_total_in_radius} ЖК с данными velocity + ({dq.velocity_coverage_pct:.1f}%) +

+

+ Уверенность: + + {dq.confidence.upper()} + +

+ + + +""" + + pdf_bytes = HTML(string=html).write_pdf() + logger.info("Generated layout TZ PDF for cad %s: %d bytes", cad_num, len(pdf_bytes)) + return pdf_bytes diff --git a/backend/tests/test_layout_tz_pdf.py b/backend/tests/test_layout_tz_pdf.py new file mode 100644 index 00000000..1fbe2dd2 --- /dev/null +++ b/backend/tests/test_layout_tz_pdf.py @@ -0,0 +1,136 @@ +"""Tests для layout_tz_pdf renderer (Issue #113 PR D). + +WeasyPrint requires native GTK/Pango/GObject shared libraries. These are present +in the Docker container (Linux) but absent on Windows dev machines. All tests in +this module are skipped automatically when the native libs are unavailable. +""" + +import datetime as dt + +import pytest + +# Attempt to import the module under test; skip entire module if native libs missing. +try: + from app.services.exporters.layout_tz_pdf import render_layout_tz_pdf +except (OSError, ImportError) as _e: # GTK libs missing on Windows, or weasyprint not installed + pytest.skip(f"WeasyPrint deps missing: {_e}", allow_module_level=True) + +from app.schemas.parcel import ( + BestLayoutsResponse, + LayoutDataQuality, + LayoutTzMixRow, + LayoutTzRecommendation, + TopLayoutRow, +) + + +def _sample_response() -> BestLayoutsResponse: + return BestLayoutsResponse( + top_layouts=[ + TopLayoutRow( + rank=1, + room_bucket="1", + area_bin="25-40", + signature="1__25-40", + competitor_obj_ids=[1234, 5678], + competitor_count=2, + total_sold_in_window=67, + velocity_per_month=8.4, + avg_price_per_m2_rub=148000.0, + avg_area_m2=38.5, + supply_units_in_radius=312, + sold_pct_of_supply=21.5, + ), + TopLayoutRow( + rank=2, + room_bucket="studio", + area_bin="<25", + signature="studio__<25", + competitor_obj_ids=[1234], + competitor_count=1, + total_sold_in_window=40, + velocity_per_month=5.0, + avg_price_per_m2_rub=160000.0, + avg_area_m2=22.0, + supply_units_in_radius=100, + sold_pct_of_supply=40.0, + ), + ], + recommendation_for_tz=LayoutTzRecommendation( + rationale_text="Test rationale текст с кириллицей", + mix=[ + LayoutTzMixRow(room_bucket="studio", pct=10, abs_units=30, avg_target_area_m2=22.0), + LayoutTzMixRow(room_bucket="1", pct=60, abs_units=180, avg_target_area_m2=38.5), + LayoutTzMixRow(room_bucket="2", pct=30, abs_units=90, avg_target_area_m2=55.0), + ], + weighted_avg_price_per_m2_rub=152000.0, + based_on_obj_count=5, + based_on_total_deals=107, + data_window_start=dt.date(2026, 2, 1), + data_window_end=dt.date(2026, 5, 1), + ), + data_quality=LayoutDataQuality( + objects_with_velocity_data=5, + objects_total_in_radius=8, + velocity_coverage_pct=62.5, + confidence="medium", + ), + ) + + +def test_pdf_renders_non_empty_bytes() -> None: + pdf = render_layout_tz_pdf( + _sample_response(), + cad_num="66:41:0204016:10", + radius_km=1.0, + time_window="last_quarter", + ) + assert len(pdf) > 1000 # PDF минимум ~1KB + + +def test_pdf_starts_with_pdf_magic() -> None: + pdf = render_layout_tz_pdf( + _sample_response(), + cad_num="66:41:0204016:10", + radius_km=1.0, + time_window="last_quarter", + ) + assert pdf[:4] == b"%PDF" + + +def test_pdf_renders_cyrillic_correctly() -> None: + """Smoke — WeasyPrint должен handle кириллический rationale_text без UnicodeEncodeError.""" + response = _sample_response() + pdf = render_layout_tz_pdf( + response, + cad_num="66:41:0303161:42", + radius_km=1.5, + time_window="last_year", + ) + # Embedded text может быть compressed, но без exception = OK + assert len(pdf) > 1000 + + +def test_pdf_handles_empty_top_layouts() -> None: + response = _sample_response() + response.top_layouts = [] + pdf = render_layout_tz_pdf( + response, + cad_num="66:41:0204016:10", + radius_km=1.0, + time_window="last_quarter", + ) + assert pdf[:4] == b"%PDF" + + +def test_pdf_handles_null_avg_price() -> None: + """avg_price_per_m2_rub=None (ЖК не покрыт Objective) → должно рендериться как '—'.""" + response = _sample_response() + response.top_layouts[0].avg_price_per_m2_rub = None + pdf = render_layout_tz_pdf( + response, + cad_num="66:41:0204016:10", + radius_km=1.0, + time_window="last_quarter", + ) + assert pdf[:4] == b"%PDF" diff --git a/frontend/src/components/site-finder/BestLayoutsBlock.tsx b/frontend/src/components/site-finder/BestLayoutsBlock.tsx new file mode 100644 index 00000000..63224696 --- /dev/null +++ b/frontend/src/components/site-finder/BestLayoutsBlock.tsx @@ -0,0 +1,761 @@ +"use client"; + +import { useState } from "react"; +import { useBestLayouts } from "@/hooks/useBestLayouts"; +import { API_BASE_URL } from "@/lib/api"; +import type { + BestLayoutsRequest, + BestLayoutsResponse, + Confidence, + LayoutTzMixRow, + TimeWindow, + TopLayoutRow, +} from "@/types/best-layouts"; + +// ── Constants ───────────────────────────────────────────────────────────────── + +const CONFIDENCE_STYLES: Record< + Confidence, + { bg: string; fg: string; label: string } +> = { + high: { bg: "#dcfce7", fg: "#166534", label: "Высокое" }, + medium: { bg: "#fef3c7", fg: "#854d0e", label: "Среднее" }, + low: { bg: "#fee2e2", fg: "#991b1b", label: "Низкое" }, +}; + +const TIME_WINDOW_LABELS: Record = { + last_month: "Последний месяц", + last_quarter: "Последний квартал", + last_year: "Последний год", +}; + +const ROOM_BUCKET_LABELS: Record = { + studio: "Студия", + "1": "1-комн.", + "2": "2-комн.", + "3": "3-комн.", + "4+": "4+ комн.", +}; + +// ── Sub-components ──────────────────────────────────────────────────────────── + +function DataQualityCard({ dq }: { dq: BestLayoutsResponse["data_quality"] }) { + const style = CONFIDENCE_STYLES[dq.confidence]; + return ( +
+ + Качество данных: + + + {style.label} + + + Покрытие {dq.velocity_coverage_pct.toFixed(0)}% ( + {dq.objects_with_velocity_data} из {dq.objects_total_in_radius} ЖК) + +
+ ); +} + +function TopLayoutsTable({ rows }: { rows: TopLayoutRow[] }) { + if (rows.length === 0) { + return ( +
+ Данных недостаточно для ранжирования планировок +
+ ); + } + + const headers = [ + "#", + "Тип", + "Площадь", + "Скорость / мес", + "Средн. площадь, м²", + "Средн. цена, ₽/м²", + "Продано, %", + ]; + + return ( +
+
+ Топ планировок ({rows.length}) +
+
+ + + + {headers.map((h) => ( + + ))} + + + + {rows.map((row, i) => ( + + + + + + + + + + ))} + +
+ {h} +
+ {row.rank} + + {ROOM_BUCKET_LABELS[row.room_bucket] ?? row.room_bucket} + + {row.area_bin} м² + + {row.velocity_per_month.toFixed(2)} + + {row.avg_area_m2.toFixed(1)} + + {row.avg_price_per_m2_rub != null + ? Math.round(row.avg_price_per_m2_rub).toLocaleString( + "ru-RU", + ) + : "—"} + + {row.sold_pct_of_supply != null + ? `${(row.sold_pct_of_supply ?? 0).toFixed(0)}%` + : "—"} +
+
+
+ ); +} + +function UnitMixBar({ mix }: { mix: LayoutTzMixRow[] }) { + const COLORS = [ + "#1d4ed8", + "#7c3aed", + "#059669", + "#d97706", + "#dc2626", + "#0891b2", + ]; + + return ( +
+ {/* Horizontal stacked bar */} +
+ {mix.map((row, i) => ( +
+ ))} +
+ {/* Legend */} +
+ {mix.map((row, i) => ( +
+
+ + {ROOM_BUCKET_LABELS[row.room_bucket] ?? row.room_bucket} {row.pct} + % + +
+ ))} +
+
+ ); +} + +function MixTable({ mix }: { mix: LayoutTzMixRow[] }) { + return ( + + + + {["Тип", "Доля, %", "Кол-во квартир", "Ср. площадь, м²"].map((h) => ( + + ))} + + + + {mix.map((row, i) => ( + + + + + + + ))} + +
+ {h} +
+ {ROOM_BUCKET_LABELS[row.room_bucket] ?? row.room_bucket} + + {row.pct}% + + {row.abs_units != null + ? row.abs_units.toLocaleString("ru-RU") + : "—"} + + {row.avg_target_area_m2 != null + ? row.avg_target_area_m2.toFixed(1) + : "—"} +
+ ); +} + +function RecommendationCard({ + rec, +}: { + rec: BestLayoutsResponse["recommendation_for_tz"]; +}) { + return ( +
+
+ Рекомендация ТЗ +
+
+ {/* Rationale text — plain text only, no dangerouslySetInnerHTML */} +

+ {rec.rationale_text} +

+ + {/* Unit-mix bar chart */} + {rec.mix.length > 0 && ( +
+
+ Unit-mix +
+ +
+ )} + + {/* Mix table */} + {rec.mix.length > 0 && ( +
+ +
+ )} + + {/* Weighted avg price */} + {rec.weighted_avg_price_per_m2_rub != null && ( +
+ Средневзвешенная цена: + + {Math.round(rec.weighted_avg_price_per_m2_rub).toLocaleString( + "ru-RU", + )}{" "} + ₽/м² + +
+ )} + + {/* Meta */} +
+ Основано на {rec.based_on_obj_count} ЖК ·{" "} + {rec.based_on_total_deals.toLocaleString("ru-RU")} сделках · период{" "} + {new Date(rec.data_window_start).toLocaleDateString("ru-RU")} —{" "} + {new Date(rec.data_window_end).toLocaleDateString("ru-RU")} +
+
+
+ ); +} + +// ── Main component ───────────────────────────────────────────────────────────── + +interface Props { + cadNum: string; + selectedCompetitorObjIds?: number[]; +} + +export function BestLayoutsBlock({ cadNum, selectedCompetitorObjIds }: Props) { + const [radiusKm, setRadiusKm] = useState(1.0); + const [timeWindow, setTimeWindow] = useState("last_quarter"); + const [targetTotalFlats, setTargetTotalFlats] = useState("300"); + const [minVelocity, setMinVelocity] = useState(0.5); + const [isPdfLoading, setIsPdfLoading] = useState(false); + const [pdfError, setPdfError] = useState(null); + + const { mutate, data, isPending, error } = useBestLayouts(cadNum); + + function buildRequest(): BestLayoutsRequest { + const parsed = parseInt(targetTotalFlats, 10); + return { + radius_km: radiusKm, + time_window: timeWindow, + filter_competitor_obj_ids: + selectedCompetitorObjIds && selectedCompetitorObjIds.length > 0 + ? selectedCompetitorObjIds + : null, + min_velocity_per_month: minVelocity, + target_total_flats: + !Number.isNaN(parsed) && parsed > 0 + ? Math.min(Math.max(parsed, 1), 10000) + : null, + }; + } + + function handleCalculate() { + mutate(buildRequest()); + } + + async function handleDownloadPdf() { + setIsPdfLoading(true); + try { + const req = buildRequest(); + const res = await fetch( + `${API_BASE_URL}/api/v1/parcels/${encodeURIComponent(cadNum)}/best-layouts/pdf`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(req), + }, + ); + if (!res.ok) { + throw new Error(`Ошибка генерации PDF: ${res.status}`); + } + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `tz-layout-${cadNum.replace(/:/g, "-")}-${new Date().toISOString().split("T")[0]}.pdf`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } catch (e) { + setPdfError(e instanceof Error ? e.message : "Не удалось скачать PDF"); + } finally { + setIsPdfLoading(false); + } + } + + return ( +
+ {/* Header */} +
+
+ + Анализ планировок + + + data-driven ТЗ на проектирование + +
+ {data && ( +
+ + {pdfError && ( + PDF: {pdfError} + )} +
+ )} +
+ + {/* Controls */} +
+ {/* Radius slider */} +
+ + setRadiusKm(parseFloat(e.target.value))} + style={{ width: 160, accentColor: "#1d4ed8" }} + /> +
+ + {/* Min velocity slider */} +
+ + setMinVelocity(parseFloat(e.target.value))} + style={{ width: 160, accentColor: "#1d4ed8" }} + /> +
+ + {/* Time window radio */} +
+ + Период анализа + +
+ {(Object.keys(TIME_WINDOW_LABELS) as TimeWindow[]).map((tw) => ( + + ))} +
+
+ + {/* Target flats input */} +
+ + setTargetTotalFlats(e.target.value)} + placeholder="300" + style={{ + padding: "5px 10px", + border: "1px solid #d1d5db", + borderRadius: 6, + fontSize: 13, + width: 110, + color: "#111827", + }} + /> +
+ + {/* Calculate button */} + + + {selectedCompetitorObjIds && selectedCompetitorObjIds.length > 0 && ( + + Фильтр: {selectedCompetitorObjIds.length} выбр. ЖК + + )} +
+ + {/* Content area */} +
+ {/* Loading skeleton */} + {isPending && ( +
+ {[80, 60, 40].map((w) => ( +
+ ))} +
+ )} + + {/* Error */} + {error && !isPending && ( +
+ {error instanceof Error ? error.message : "Ошибка получения данных"} +
+ )} + + {/* Results */} + {data && !isPending && ( +
+ + + +
+ )} + + {/* Idle state */} + {!isPending && !error && !data && ( +
+ Настройте параметры и нажмите «Рассчитать» +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/site-finder/MarketTab.tsx b/frontend/src/components/site-finder/MarketTab.tsx index 4b408e1b..cfbc4df5 100644 --- a/frontend/src/components/site-finder/MarketTab.tsx +++ b/frontend/src/components/site-finder/MarketTab.tsx @@ -4,6 +4,7 @@ import type { ParcelAnalysis } from "@/types/site-finder"; import { SectionLabel } from "@/components/ui/SectionLabel"; import { EmptyState } from "@/components/ui/EmptyState"; import { MarketTrendBlock } from "./MarketTrendBlock"; +import { BestLayoutsBlock } from "./BestLayoutsBlock"; import { CompetitorTable } from "./CompetitorTable"; import { Pipeline24moBlock } from "./Pipeline24moBlock"; import { SuccessRecommendationBlock } from "./SuccessRecommendationBlock"; @@ -81,6 +82,9 @@ export function MarketTab({ data }: Props) {
)} + {/* Issue #113 — data-driven ТЗ на проектирование */} + + {!hasAny && }
); diff --git a/frontend/src/hooks/useBestLayouts.ts b/frontend/src/hooks/useBestLayouts.ts new file mode 100644 index 00000000..f8928ff5 --- /dev/null +++ b/frontend/src/hooks/useBestLayouts.ts @@ -0,0 +1,29 @@ +"use client"; + +import { useMutation } from "@tanstack/react-query"; +import { apiFetch } from "@/lib/api"; +import type { + BestLayoutsRequest, + BestLayoutsResponse, +} from "@/types/best-layouts"; + +/** + * TanStack Query mutation for POST /api/v1/parcels/{cad_num}/best-layouts. + * + * Usage: + * const { mutate, data, isPending, error } = useBestLayouts(cadNum); + * mutate(requestBody); + */ +export function useBestLayouts(cadNum: string) { + return useMutation({ + mutationKey: ["best-layouts", cadNum], + mutationFn: (body: BestLayoutsRequest): Promise => + apiFetch( + `/api/v1/parcels/${encodeURIComponent(cadNum)}/best-layouts`, + { + method: "POST", + body: JSON.stringify(body), + }, + ), + }); +} diff --git a/frontend/src/types/best-layouts.ts b/frontend/src/types/best-layouts.ts new file mode 100644 index 00000000..15015fab --- /dev/null +++ b/frontend/src/types/best-layouts.ts @@ -0,0 +1,63 @@ +// Manual TS types for /best-layouts endpoint (Issue #113) +// Source: backend/app/schemas/parcel.py — BestLayoutsRequest, BestLayoutsResponse et al. +// Update if Pydantic schemas change and codegen is available. + +export type TimeWindow = "last_month" | "last_quarter" | "last_year"; +export type RoomBucket = "studio" | "1" | "2" | "3" | "4+"; +export type AreaBin = "<25" | "25-40" | "40-60" | "60-80" | "80-100" | "100+"; +export type Confidence = "high" | "medium" | "low"; + +export interface BestLayoutsRequest { + radius_km: number; + time_window: TimeWindow; + filter_competitor_obj_ids?: number[] | null; + exclude_competitor_obj_ids?: number[]; + min_velocity_per_month?: number; + obj_class_filter?: "economy" | "comfort" | "business" | null; + target_total_flats?: number | null; +} + +export interface TopLayoutRow { + rank: number; + room_bucket: string; + area_bin: string; + signature: string; + competitor_obj_ids: number[]; + competitor_count: number; + total_sold_in_window: number; + velocity_per_month: number; + avg_price_per_m2_rub: number | null; + avg_area_m2: number; + supply_units_in_radius: number; + sold_pct_of_supply: number | null; +} + +export interface LayoutTzMixRow { + room_bucket: string; + pct: number; + abs_units: number | null; + avg_target_area_m2: number | null; +} + +export interface LayoutTzRecommendation { + rationale_text: string; + mix: LayoutTzMixRow[]; + weighted_avg_price_per_m2_rub: number | null; + based_on_obj_count: number; + based_on_total_deals: number; + data_window_start: string; + data_window_end: string; +} + +export interface LayoutDataQuality { + objects_with_velocity_data: number; + objects_total_in_radius: number; + velocity_coverage_pct: number; + confidence: Confidence; +} + +export interface BestLayoutsResponse { + top_layouts: TopLayoutRow[]; + recommendation_for_tz: LayoutTzRecommendation; + data_quality: LayoutDataQuality; +} From 092976f6562f87810ddbab3fee5e7936856046bf Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 16 May 2026 11:00:41 +0000 Subject: [PATCH 010/342] =?UTF-8?q?feat(site-finder):=20inline=20POI=20wei?= =?UTF-8?q?ghts=20pass-through=20=D0=B2=20/analyze=20(#201=20Phase=201)=20?= =?UTF-8?q?(#206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/v1/parcels.py | 84 +++-- backend/app/schemas/parcel.py | 21 ++ .../services/site_finder/weight_profiles.py | 7 +- .../api/v1/test_analyze_inline_weights.py | 327 ++++++++++++++++++ frontend/src/app/site-finder/page.tsx | 24 +- frontend/src/hooks/useSiteAnalysis.ts | 23 ++ 6 files changed, 453 insertions(+), 33 deletions(-) create mode 100644 backend/tests/api/v1/test_analyze_inline_weights.py diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 075983ab..f5cab072 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -6,7 +6,7 @@ import time from typing import Annotated, Any import httpx -from fastapi import APIRouter, Depends, HTTPException, Query, Response +from fastapi import APIRouter, Body, Depends, HTTPException, Query, Response from shapely import wkt as _shp_wkt from shapely.geometry import Polygon from sqlalchemy import text @@ -15,6 +15,7 @@ from sqlalchemy.orm import Session from app.core.config import settings from app.core.db import get_db from app.schemas.parcel import ( + AnalyzeRequest, BestLayoutsRequest, BestLayoutsResponse, CompetitorsRequest, @@ -41,6 +42,21 @@ from app.services.site_finder.quarter_dump_lookup import ( make_empty_result, ) from app.services.site_finder.velocity import compute_velocity +from app.services.site_finder.weight_profiles import ( + _SYSTEM_POI_WEIGHTS as _POI_WEIGHTS, +) +from app.services.site_finder.weight_profiles import ( + ALLOWED_CATEGORIES as _ALLOWED_CATEGORIES, +) +from app.services.site_finder.weight_profiles import ( + MAX_WEIGHT as _MAX_WEIGHT, +) +from app.services.site_finder.weight_profiles import ( + MIN_WEIGHT as _MIN_WEIGHT, +) +from app.services.site_finder.weight_profiles import ( + resolve_weights as _resolve_weights, +) logger = logging.getLogger(__name__) @@ -285,21 +301,6 @@ def _confidence_label(c: float) -> str: return "low" -# Веса POI-категорий для scoring (Максим: трамвай = минус) -_POI_WEIGHTS: dict[str, float] = { - "school": 1.5, - "kindergarten": 1.5, - "pharmacy": 0.8, - "hospital": 0.6, - "shop_mall": 1.2, - "shop_supermarket": 1.0, - "shop_small": 0.5, - "park": 1.8, - "bus_stop": 0.3, - "metro_stop": 1.5, - "tram_stop": -0.5, # негативный вес — шум / вибрация -} - # Человеко-читаемые имена категорий для verbal breakdown (X1). _POI_CATEGORY_RU: dict[str, str] = { "school": "Школа", @@ -1048,6 +1049,10 @@ def analyze_parcel( str | None, Query(description="user_id для fallback на default-профиль пользователя"), ] = None, + body: Annotated[ + AnalyzeRequest | None, + Body(description="Опциональное тело запроса: inline POI-веса (#201)"), + ] = None, ) -> dict[str, Any]: """Анализ участка: близость к социалке + district context + конкуренты. @@ -1223,15 +1228,43 @@ def analyze_parcel( .all() ) - # 3b) Resolve effective POI weights (profile → user default → system) - from app.services.site_finder.weight_profiles import resolve_weights as _resolve_weights + # 3b) Resolve effective POI weights (inline → profile → user default → system) + _inline_weights: dict[str, float] | None = body.weights if body is not None else None - _effective_weights = _resolve_weights(db, user_id=profile_user_id, profile_id=profile_id) - _weights_source = ( - "profile" - if profile_id is not None - else ("user_default" if profile_user_id is not None else "system") - ) + if _inline_weights is not None: + # Validate inline weights: keys и диапазон значений (#201) + bad_keys = set(_inline_weights.keys()) - _ALLOWED_CATEGORIES + if bad_keys: + raise HTTPException( + status_code=422, + detail=( + f"Неизвестные POI-категории: {sorted(bad_keys)}. " + f"Допустимые: {sorted(_ALLOWED_CATEGORIES)}" + ), + ) + out_of_range = { + k: v + for k, v in _inline_weights.items() + if not math.isfinite(v) or v < _MIN_WEIGHT or v > _MAX_WEIGHT + } + if out_of_range: + raise HTTPException( + status_code=422, + detail=( + f"Веса за пределами допустимого диапазона " + f"[{_MIN_WEIGHT}, {_MAX_WEIGHT}]: {out_of_range}" + ), + ) + # Inline weights applied — merge поверх системных defaults (partial override) + _effective_weights = {**_POI_WEIGHTS, **_inline_weights} + _weights_source = "inline" + else: + _effective_weights = _resolve_weights(db, user_id=profile_user_id, profile_id=profile_id) + _weights_source = ( + "profile" + if profile_id is not None + else ("user_default" if profile_user_id is not None else "system") + ) # 4) Scoring: weighted sum с distance decay score = 0.0 @@ -1926,12 +1959,13 @@ def analyze_parcel( nspd_engineering_nearby=nspd_dump_data["nspd_engineering_nearby"], nspd_dump=nspd_dump_data["nspd_dump"], ), - # #114: кастомные веса POI — source + applied dict для прозрачности. + # #114/#201: кастомные веса POI — source + applied dict для прозрачности. "weights_profile": { "source": _weights_source, "profile_id": profile_id, "user_id": profile_user_id, "weights_applied": _effective_weights, + "inline_weights": _inline_weights, }, } diff --git a/backend/app/schemas/parcel.py b/backend/app/schemas/parcel.py index 0a948e67..851904cd 100644 --- a/backend/app/schemas/parcel.py +++ b/backend/app/schemas/parcel.py @@ -213,3 +213,24 @@ class BestLayoutsResponse(BaseModel): top_layouts: list[TopLayoutRow] recommendation_for_tz: LayoutTzRecommendation data_quality: LayoutDataQuality + + +# ── Analyze endpoint inline weights (#201) ──────────────────────────────────── + + +class AnalyzeRequest(BaseModel): + """Опциональное тело запроса POST /analyze. + + Позволяет передать inline POI-веса напрямую в запросе без сохранения + профиля. Если задан weights — применяется с наивысшим приоритетом + (выше profile_id и user default). + """ + + weights: dict[str, float] | None = Field( + default=None, + description=( + "Inline POI weights override (категория → weight). " + "Если задан — применяется к scoring, без обязательного profile save. " + "Validated против ALLOWED_CATEGORIES + MIN_WEIGHT/MAX_WEIGHT." + ), + ) diff --git a/backend/app/services/site_finder/weight_profiles.py b/backend/app/services/site_finder/weight_profiles.py index 7f3f0ad2..12259896 100644 --- a/backend/app/services/site_finder/weight_profiles.py +++ b/backend/app/services/site_finder/weight_profiles.py @@ -17,6 +17,7 @@ from __future__ import annotations import json import logging +import math from datetime import datetime from typing import Any @@ -25,7 +26,7 @@ from sqlalchemy import text logger = logging.getLogger(__name__) -# Allowed POI categories — mirrors _POI_WEIGHTS keys in api/v1/parcels.py +# Allowed POI categories — single source of truth; imported by api/v1/parcels.py ALLOWED_CATEGORIES: set[str] = { "school", "kindergarten", @@ -44,7 +45,7 @@ ALLOWED_CATEGORIES: set[str] = { MIN_WEIGHT: float = -2.0 MAX_WEIGHT: float = 3.0 -# System defaults — keep in sync with _POI_WEIGHTS in parcels.py +# System defaults — single source of truth; imported as _POI_WEIGHTS by api/v1/parcels.py _SYSTEM_POI_WEIGHTS: dict[str, float] = { "school": 1.5, "kindergarten": 1.5, @@ -115,7 +116,7 @@ def _validate_weights_dict(v: dict[str, float]) -> dict[str, float]: for k, w in v.items(): if not isinstance(w, int | float): raise ValueError(f"Weight for '{k}' must be number, got {type(w).__name__}") - if w < MIN_WEIGHT or w > MAX_WEIGHT: + if not math.isfinite(w) or w < MIN_WEIGHT or w > MAX_WEIGHT: raise ValueError(f"Weight for '{k}' = {w} out of bounds [{MIN_WEIGHT}, {MAX_WEIGHT}]") return v diff --git a/backend/tests/api/v1/test_analyze_inline_weights.py b/backend/tests/api/v1/test_analyze_inline_weights.py new file mode 100644 index 00000000..80a1a9d6 --- /dev/null +++ b/backend/tests/api/v1/test_analyze_inline_weights.py @@ -0,0 +1,327 @@ +"""Тесты для inline POI-weights в POST /api/v1/parcels/{cad_num}/analyze (#201). + +Покрывает: +1. POST /analyze без body → system defaults (no regression) +2. POST /analyze с inline weights → applied (source = "inline") +3. POST /analyze с невалидной категорией → 422 +4. POST /analyze с весом вне диапазона → 422 +5. POST /analyze с body.weights + profile_id → body.weights wins (priority) + +Стратегия mock: DB патчим через dependency_overrides, тяжёлые service-функции +(weather, velocity, dump и т.д.) патчим через unittest.mock.patch — чтобы не +дублировать все 18 db.execute call'ов в каждом тесте. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +from app.main import app + +# ── Константы ───────────────────────────────────────────────────────────────── + +_CAD = "66:41:0204016:10" +_WKT = "POLYGON((60.6 56.838, 60.61 56.838, 60.61 56.845, 60.6 56.845, 60.6 56.838))" +_GEOJSON = '{"type":"Polygon","coordinates":[[[60.6,56.838],[60.61,56.838]]]}' + + +# ── Mock factories ───────────────────────────────────────────────────────────── + + +def _make_mapping(data: dict[str, Any]) -> MagicMock: + """Создать mock-строку (mapping) с __getitem__ + .get() для dict-like доступа.""" + m = MagicMock() + m.__getitem__ = lambda self, k: data[k] + m.get = lambda k, default=None: data.get(k, default) + return m + + +def _make_db_for_analyze( + geom_found: bool = True, + district_found: bool = True, + poi_rows: list[Any] | None = None, +) -> MagicMock: + """Сконструировать mock DB Session для analyze_parcel. + + Порядок db.execute calls в analyze_parcel: + 0. UNION ALL geom + source → .mappings().first() + 1. WKT query → .mappings().first() + 2. District → .mappings().first() + 3. POI rows → .mappings().all() + 4. Competitor rows → .mappings().all() + 5. Pipeline rows → .mappings().all() + 6. Centroid lat/lon → .mappings().first() + 7. Noise rows → .mappings().all() + 8. Hydrology → .mappings().all() + 9. Utilities → .mappings().all() + 10. Market trend → .mappings().first() + 11. Zoning (begin_nested) → .mappings().first() + 12. Success recommendation (begin_nested) → .mappings().all() + 13. _geotech_risk (industrial count) → .scalar() + 14. _neighbors_summary (neighbor_rows) → .mappings().all() + 15. _neighbors_summary (overlap_row) → .mappings().first() + + begin_nested() — возвращаем context manager чтобы поддержать `with` statement. + """ + db = MagicMock() + + geom_row = ( + _make_mapping({"geom_geojson": _GEOJSON, "geom_wkb": None, "source": "cad_quarter"}) + if geom_found + else None + ) + wkt_row = _make_mapping({"wkt": _WKT}) if geom_found else None + district_row = ( + _make_mapping( + { + "district_name": "Октябрьский", + "median_price_per_m2": 120000, + "dist_to_center": 1500.0, + } + ) + if district_found + else None + ) + centroid_row = _make_mapping({"lat": 56.84, "lon": 60.605}) + + _poi_rows = poi_rows or [] + + # Счётчик вызовов execute — разводим first() / all() / scalar() по очерёдности + call_idx = [0] + # Ответы в порядке вызовов: + responses: list[Any] = [ + ("first", geom_row), # 0: geom UNION ALL + ("first", wkt_row), # 1: WKT + ("first", district_row), # 2: district + ("all", _poi_rows), # 3: POI rows + ("all", []), # 4: competitor rows + ("all", []), # 5: pipeline rows + ("first", centroid_row), # 6: centroid + ("all", []), # 7: noise rows + ("all", []), # 8: hydrology rows + ("all", []), # 9: utilities rows + ("first", None), # 10: market trend + ("first", None), # 11: zoning (inside begin_nested) + ("all", []), # 12: success recommendation (inside begin_nested) + ("scalar", 0), # 13: geotech_risk industrial count + ("all", []), # 14: neighbors + ("first", None), # 15: overlap + ] + + def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock: + idx = call_idx[0] + call_idx[0] += 1 + if idx >= len(responses): + # Безопасный fallback для непредусмотренных вызовов + r = MagicMock() + r.mappings.return_value.first.return_value = None + r.mappings.return_value.all.return_value = [] + r.scalar.return_value = 0 + return r + kind, data = responses[idx] + r = MagicMock() + r.mappings.return_value.first.return_value = data + r.mappings.return_value.all.return_value = data if isinstance(data, list) else [] + r.scalar.return_value = data if kind == "scalar" else 0 + return r + + db.execute.side_effect = _execute_side_effect + + # begin_nested() → context manager, остальные execute внутри него проходят + # через тот же side_effect (because db.execute is the same mock). + ctx = MagicMock() + ctx.__enter__ = MagicMock(return_value=ctx) + ctx.__exit__ = MagicMock(return_value=False) + db.begin_nested.return_value = ctx + + return db + + +def _override_db(db: MagicMock): + def _get_db_override(): + yield db + + return _get_db_override + + +# Патчим тяжёлые внешние вызовы (weather / velocity / nspd-dump), +# чтобы тесты не зависели от сети и не требовали полного mock DB. +_PATCHES = [ + patch("app.api.v1.parcels._fetch_air_quality_sync", return_value=None), + patch("app.api.v1.parcels._fetch_weather_sync", return_value=None), + patch("app.api.v1.parcels._fetch_seasonal_weather_sync", return_value=None), + patch( + "app.api.v1.parcels.get_quarter_dump_data", + return_value={ + "nspd_zoning": None, + "nspd_zouit_overlaps": [], + "nspd_engineering_nearby": [], + "nspd_dump": {"available": False, "stale": False, "harvest_triggered": False}, + }, + ), + patch("app.api.v1.parcels.compute_velocity", return_value=None), + patch("app.api.v1.parcels.compute_gate_verdict", return_value={"verdict": "unknown"}), +] + + +def _start_patches() -> list[Any]: + started = [p.start() for p in _PATCHES] + return started + + +def _stop_patches() -> None: + for p in _PATCHES: + p.stop() + + +# ── Тесты ───────────────────────────────────────────────────────────────────── + + +def test_analyze_no_body_uses_system_defaults() -> None: + """POST /analyze без body → source = 'system', нет регрессии.""" + from app.core.db import get_db + + db = _make_db_for_analyze() + app.dependency_overrides[get_db] = _override_db(db) + _start_patches() + try: + client = TestClient(app) + resp = client.post(f"/api/v1/parcels/{_CAD}/analyze") + assert resp.status_code == 200, resp.text + body = resp.json() + assert "weights_profile" in body + assert body["weights_profile"]["source"] == "system" + assert body["weights_profile"]["inline_weights"] is None + finally: + app.dependency_overrides.clear() + _stop_patches() + + +def test_analyze_inline_weights_applied() -> None: + """POST /analyze с body.weights → source = 'inline', веса применены.""" + from app.core.db import get_db + + db = _make_db_for_analyze() + app.dependency_overrides[get_db] = _override_db(db) + _start_patches() + try: + client = TestClient(app) + resp = client.post( + f"/api/v1/parcels/{_CAD}/analyze", + json={"weights": {"kindergarten": 2.5}}, + ) + assert resp.status_code == 200, resp.text + body = resp.json() + wp = body["weights_profile"] + assert wp["source"] == "inline" + assert wp["inline_weights"] == {"kindergarten": 2.5} + # applied weights содержат inline override поверх defaults + assert wp["weights_applied"]["kindergarten"] == pytest.approx(2.5) + finally: + app.dependency_overrides.clear() + _stop_patches() + + +def test_analyze_invalid_category_returns_422() -> None: + """POST /analyze с невалидной POI-категорией → 422.""" + from app.core.db import get_db + + db = _make_db_for_analyze() + app.dependency_overrides[get_db] = _override_db(db) + _start_patches() + try: + client = TestClient(app) + resp = client.post( + f"/api/v1/parcels/{_CAD}/analyze", + json={"weights": {"nonexistent_category": 1.0}}, + ) + assert resp.status_code == 422, resp.text + detail = resp.json()["detail"] + assert "nonexistent_category" in detail + finally: + app.dependency_overrides.clear() + _stop_patches() + + +def test_analyze_weight_out_of_range_returns_422() -> None: + """POST /analyze с весом вне [-2, 3] → 422.""" + from app.core.db import get_db + + db = _make_db_for_analyze() + app.dependency_overrides[get_db] = _override_db(db) + _start_patches() + try: + client = TestClient(app) + # Слишком большой вес + resp = client.post( + f"/api/v1/parcels/{_CAD}/analyze", + json={"weights": {"school": 99.9}}, + ) + assert resp.status_code == 422, resp.text + detail = resp.json()["detail"] + assert "school" in detail + + # Слишком маленький вес + resp2 = client.post( + f"/api/v1/parcels/{_CAD}/analyze", + json={"weights": {"park": -5.0}}, + ) + assert resp2.status_code == 422, resp2.text + assert "park" in resp2.json()["detail"] + finally: + app.dependency_overrides.clear() + _stop_patches() + + +def test_inline_weights_rejects_nan() -> None: + """NaN weight должен вернуть 422, а не propagate в score.""" + from app.core.db import get_db + + db = _make_db_for_analyze() + app.dependency_overrides[get_db] = _override_db(db) + _start_patches() + try: + client = TestClient(app) + # Отправляем raw JSON с NaN — httpx.Client не умеет encode float('nan'), + # поэтому используем content= с явным bytes-телом. + raw_body = b'{"weights": {"school": NaN}}' + resp = client.post( + f"/api/v1/parcels/{_CAD}/analyze", + content=raw_body, + headers={"Content-Type": "application/json"}, + ) + assert ( + resp.status_code == 422 + ), f"Ожидали 422 для NaN-weight, получили {resp.status_code}: {resp.text}" + finally: + app.dependency_overrides.clear() + _stop_patches() + + +def test_analyze_inline_weights_beats_profile_id() -> None: + """body.weights + profile_id → body.weights имеет приоритет (source = 'inline').""" + from app.core.db import get_db + + db = _make_db_for_analyze() + app.dependency_overrides[get_db] = _override_db(db) + _start_patches() + try: + client = TestClient(app) + # Передаём и profile_id=1, и inline weights — inline должен победить + resp = client.post( + f"/api/v1/parcels/{_CAD}/analyze?profile_id=1", + json={"weights": {"metro_stop": 2.0}}, + ) + assert resp.status_code == 200, resp.text + wp = resp.json()["weights_profile"] + assert wp["source"] == "inline", f"Ожидали source='inline', получили '{wp['source']}'" + assert wp["weights_applied"]["metro_stop"] == pytest.approx(2.0) + # profile_id всё ещё присутствует в ответе для трассировки + assert wp["profile_id"] == 1 + finally: + app.dependency_overrides.clear() + _stop_patches() diff --git a/frontend/src/app/site-finder/page.tsx b/frontend/src/app/site-finder/page.tsx index faeddd61..5f914ca2 100644 --- a/frontend/src/app/site-finder/page.tsx +++ b/frontend/src/app/site-finder/page.tsx @@ -147,14 +147,18 @@ function SiteFinderContent() { function handleAnalyze(cadNum: string) { setIsochrones(undefined); setTab("overview"); + // Priority: named profile → user default profile → inline draft weights. + // When activeProfileId is set, backend uses that profile (ignores inline). + // When no profile is selected, pass currentWeights as inline so draft + // slider values are always respected even without a saved profile (#201). mutate({ cad: cadNum, options: activeProfileId != null ? { profileId: activeProfileId } : profileUserId - ? { profileUserId } - : undefined, + ? { profileUserId, weights: currentWeights } + : { weights: currentWeights }, }); } @@ -163,10 +167,20 @@ function SiteFinderContent() { profileId: number | null, ) { setCurrentWeights(weights); - // Store the active profile id so we can pass it to the analyze call. - // If user edited weights without saving a named profile, profileId=null - // and backend will use system defaults (sub-PR 5 would enable inline weights). setActiveProfileId(profileId); + // Re-analyze with the new weights if a parcel is already loaded (#201). + if (data?.cad_num) { + setIsochrones(undefined); + mutate({ + cad: data.cad_num, + options: + profileId != null + ? { profileId } + : profileUserId + ? { profileUserId, weights } + : { weights }, + }); + } } // Derive KPI values from data diff --git a/frontend/src/hooks/useSiteAnalysis.ts b/frontend/src/hooks/useSiteAnalysis.ts index 7a2c064c..7abd1a64 100644 --- a/frontend/src/hooks/useSiteAnalysis.ts +++ b/frontend/src/hooks/useSiteAnalysis.ts @@ -44,6 +44,11 @@ export interface AnalyzeOptions { profileId?: number; /** If set together with no profileId, backend uses user's default profile. */ profileUserId?: string; + /** + * Inline POI weights override — sent as request body. + * Priority: inline → profileId → profileUserId default → system. + */ + weights?: Record | null; } /** @@ -90,11 +95,23 @@ export function useSiteAnalysis() { return qsStr ? `${base}?${qsStr}` : base; }; + // Build optional JSON body for inline weights (#201). + const bodyPayload = + options?.weights != null + ? JSON.stringify({ weights: options.weights }) + : undefined; + // First request — POST /analyze const first = await apiFetchWithStatus< ParcelAnalysis | AnalyzeAcceptedResponse >(analyzeUrl(cad), { method: "POST", + ...(bodyPayload + ? { + body: bodyPayload, + headers: { "Content-Type": "application/json" }, + } + : {}), }); if (first.status === 200) { @@ -127,6 +144,12 @@ export function useSiteAnalysis() { // mutation сразу резолвится с data — render skipped. const second = await apiFetch(analyzeUrl(cad), { method: "POST", + ...(bodyPayload + ? { + body: bodyPayload, + headers: { "Content-Type": "application/json" }, + } + : {}), }); setFetchingState(null); return second; From efa66a9f68721cd6cf0909f39aa7fdf3617ed8d3 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 16 May 2026 11:12:01 +0000 Subject: [PATCH 011/342] feat(analytics): mv_quarter_price_per_m2 MV + refresh helper (#33 D1 PR A) (#209) --- .../site_finder/quarter_price_refresh.py | 63 +++++++++ data/sql/95_mv_quarter_price.sql | 124 ++++++++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 backend/app/services/site_finder/quarter_price_refresh.py create mode 100644 data/sql/95_mv_quarter_price.sql diff --git a/backend/app/services/site_finder/quarter_price_refresh.py b/backend/app/services/site_finder/quarter_price_refresh.py new file mode 100644 index 00000000..97e83891 --- /dev/null +++ b/backend/app/services/site_finder/quarter_price_refresh.py @@ -0,0 +1,63 @@ +"""Refresh helper for mv_quarter_price_per_m2 (Issue #33 D1). + +Not scheduled automatically in this PR — intended for manual invocation or +a Celery beat task in a follow-up issue (Issue #33 PR C). + +Usage example (manual, via psql-connected shell or admin endpoint): + from sqlalchemy.orm import Session + from app.services.site_finder.quarter_price_refresh import refresh_quarter_price + + count = refresh_quarter_price(db) + # logs: "mv_quarter_price_per_m2 refreshed: 52492 rows" +""" + +from __future__ import annotations + +import logging + +from sqlalchemy import text +from sqlalchemy.exc import OperationalError +from sqlalchemy.orm import Session + +logger = logging.getLogger(__name__) + + +def refresh_quarter_price(db: Session, *, concurrently: bool = True) -> int: + """REFRESH MATERIALIZED VIEW mv_quarter_price_per_m2. + + Args: + db: SQLAlchemy Session (sync). + concurrently: When True, uses REFRESH CONCURRENTLY — non-blocking but + requires the unique index mv_quarter_price_pk to exist (created by + 95_mv_quarter_price.sql) and the MV to be already populated. + Pass False only for the very first populate or after MV recreation. + + Returns: + Row count in the MV after refresh (for observability / alerting). + + Raises: + OperationalError: Re-raised if the error is not the known + "cannot refresh materialized view concurrently" case. + """ + try: + if concurrently: + db.execute(text("REFRESH MATERIALIZED VIEW CONCURRENTLY mv_quarter_price_per_m2")) + else: + db.execute(text("REFRESH MATERIALIZED VIEW mv_quarter_price_per_m2")) + db.commit() + except OperationalError as e: + if concurrently and "cannot refresh materialized view" in str(e).lower(): + logger.warning( + "CONCURRENTLY failed (MV likely not populated), falling back to" + " non-concurrent refresh" + ) + db.rollback() + db.execute(text("REFRESH MATERIALIZED VIEW mv_quarter_price_per_m2")) + db.commit() + else: + raise + + row = db.execute(text("SELECT COUNT(*) FROM mv_quarter_price_per_m2")).first() + count = int(row[0]) if row else 0 + logger.info("mv_quarter_price_per_m2 refreshed: %d rows", count) + return count diff --git a/data/sql/95_mv_quarter_price.sql b/data/sql/95_mv_quarter_price.sql new file mode 100644 index 00000000..9e5d5c03 --- /dev/null +++ b/data/sql/95_mv_quarter_price.sql @@ -0,0 +1,124 @@ +-- 95_mv_quarter_price.sql +-- Issue #33 D1 — Per-cad_quarter price aggregation из 6.83M rosreestr_deals. +-- +-- Real schema (verified via information_schema): +-- quarter_cad_number varchar — cadastral quarter key (e.g. '66:41:0301036') +-- price_per_sqm numeric — pre-computed руб/м²; filled for 6.82M / 6.83M rows +-- deal_price numeric — total deal price (fallback if price_per_sqm IS NULL) +-- area numeric — area m² (fallback denominator) +-- period_start_date date — quarter start (2024-01-01 .. 2026-01-01) +-- realestate_type_code text — '002001003000' = новостройки (ДДУ) +-- +-- Filter scope: +-- realestate_type_code = '002001003000' → 3 143 004 rows (45.9% of total 6.83M) +-- price_per_sqm BETWEEN 30000 AND 800000 → outlier removal +-- period_start_date >= NOW() - INTERVAL '24 months' → rolling window +-- HAVING COUNT(*) >= 3 → suppress quarters with too few deals +-- +-- Expected rows: ~52 492 distinct quarters pass HAVING >= 3 (verified via SELECT COUNT). +-- Dataset is all-Russia (rosreestr_deals is not region-filtered at table level). +-- +-- Sub-window medians (6m / 12m): +-- PERCENTILE_CONT does not support FILTER clause in PG16. +-- Workaround: pre-aggregate per quarter with separate FILTER masks in a lateral +-- subquery. Implemented as a second CTE (agg_windows) joined to the main agg. +-- +-- REFRESH CONCURRENTLY: safe after this migration because a UNIQUE INDEX on +-- quarter_cad_number is created immediately after CREATE WITH NO DATA. +-- First populate is non-concurrent (MV is empty, CONCURRENTLY not allowed). +-- +-- Deploy: auto-applied by deploy.yml via _schema_migrations tracking. +-- Dependencies: none (no existing views depend on this MV at creation time). +-- +-- WARNING: Re-apply drops MV + CASCADE; after re-apply first REFRESH must use +-- concurrently=False. _schema_migrations tracking prevents re-apply in prod. + +BEGIN; + +DROP MATERIALIZED VIEW IF EXISTS mv_quarter_price_per_m2 CASCADE; + +CREATE MATERIALIZED VIEW mv_quarter_price_per_m2 AS +WITH dataset AS ( + -- Resolve price_m2: use pre-computed field, fall back to deal_price / area + SELECT + quarter_cad_number, + CASE + WHEN price_per_sqm IS NOT NULL THEN price_per_sqm + WHEN deal_price IS NOT NULL AND area > 0 THEN deal_price / area + ELSE NULL + END AS price_m2, + period_start_date AS deal_date + FROM rosreestr_deals + WHERE realestate_type_code = '002001003000' + AND period_start_date >= NOW() - INTERVAL '24 months' +), +filtered AS ( + -- Apply outlier filter once; reused by both aggregate CTEs + SELECT quarter_cad_number, price_m2, deal_date + FROM dataset + WHERE price_m2 BETWEEN 30000 AND 800000 +), +agg_main AS ( + -- Primary aggregation: full 24-month window + SELECT + quarter_cad_number, + COUNT(*) AS deals_count, + PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY price_m2) AS p25, + PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY price_m2) AS median, + PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY price_m2) AS p75, + AVG(price_m2)::numeric(12, 2) AS mean, + MAX(deal_date) AS last_deal_date + FROM filtered + GROUP BY quarter_cad_number + HAVING COUNT(*) >= 3 +), +agg_windows AS ( + -- Sub-window medians per quarter. + -- PERCENTILE_CONT does not accept a FILTER clause in PG16, so we pre-filter + -- per window inside separate conditional aggregations using a CASE that + -- returns NULL for out-of-window rows (NULLs are excluded from PERCENTILE_CONT). + SELECT + quarter_cad_number, + PERCENTILE_CONT(0.50) WITHIN GROUP ( + ORDER BY CASE WHEN deal_date > NOW() - INTERVAL '6 months' + THEN price_m2 ELSE NULL END + ) AS median_6m, + PERCENTILE_CONT(0.50) WITHIN GROUP ( + ORDER BY CASE WHEN deal_date > NOW() - INTERVAL '12 months' + THEN price_m2 ELSE NULL END + ) AS median_12m + FROM filtered + GROUP BY quarter_cad_number +) +SELECT + m.quarter_cad_number, + m.deals_count, + m.p25, + m.median, + m.p75, + m.mean, + m.last_deal_date, + w.median_6m, + w.median_12m, + -- median_24m == median (window is already 24mo); kept for API symmetry + m.median AS median_24m +FROM agg_main m +JOIN agg_windows w USING (quarter_cad_number) +WITH NO DATA; + +-- UNIQUE index required for REFRESH MATERIALIZED VIEW CONCURRENTLY. +-- Created on empty MV → instant (no data lock). +CREATE UNIQUE INDEX mv_quarter_price_pk + ON mv_quarter_price_per_m2 (quarter_cad_number); + +-- Initial populate (non-concurrent — CONCURRENTLY not allowed on empty MV). +REFRESH MATERIALIZED VIEW mv_quarter_price_per_m2; + +COMMENT ON MATERIALIZED VIEW mv_quarter_price_per_m2 IS + 'Per-cad_quarter (quarter_cad_number) price aggregation из rosreestr_deals. ' + 'Filter: realestate_type_code=002001003000 (новостройки/ДДУ), rolling 24 months, ' + 'outlier filter 30K–800K руб/м², HAVING >= 3 deals. ' + 'All-Russia scope (~52K rows expected). ' + 'Refresh: weekly via celery beat (TBD, Issue #33 PR C).'; + +COMMIT; From 27a0957bb5e2fba478cb3a25b9463bfab4c0bd45 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 16 May 2026 11:28:16 +0000 Subject: [PATCH 012/342] =?UTF-8?q?feat(analyze):=20add=20market=5Fprice?= =?UTF-8?q?=20=D0=B8=D0=B7=20mv=5Fquarter=5Fprice=5Fper=5Fm2=20(#33=20PR?= =?UTF-8?q?=20B)=20(#210)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/v1/parcels.py | 51 ++++ backend/app/schemas/parcel.py | 22 ++ .../tests/api/v1/test_analyze_market_price.py | 263 ++++++++++++++++++ 3 files changed, 336 insertions(+) create mode 100644 backend/tests/api/v1/test_analyze_market_price.py diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index f5cab072..20edea76 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -1800,6 +1800,55 @@ def analyze_parcel( logger.warning("success_recommendation query failed for %s: %s", cad_num, e) success_recommendation = None + # 10d-pre) Market price — ценовая статистика квартала из mv_quarter_price_per_m2 (#33) + # quarter_cad_number — первые три части кад. номера: "66:41:0204016:10" → "66:41:0204016" + _cad_parts = cad_num.split(":") + _quarter_cad = ":".join(_cad_parts[:3]) if len(_cad_parts) >= 3 else cad_num + market_price: dict[str, Any] + try: + with db.begin_nested(): + mp_row = ( + db.execute( + text(""" + SELECT p25, median, p75, mean, deals_count, + median_6m, median_12m, median_24m, last_deal_date + FROM mv_quarter_price_per_m2 + WHERE quarter_cad_number = :q + """), + {"q": _quarter_cad}, + ) + .mappings() + .first() + ) + if mp_row: + market_price = { + "p25": float(mp_row["p25"]) if mp_row["p25"] is not None else None, + "median": float(mp_row["median"]) if mp_row["median"] is not None else None, + "p75": float(mp_row["p75"]) if mp_row["p75"] is not None else None, + "mean": float(mp_row["mean"]) if mp_row["mean"] is not None else None, + "deals_count": int(mp_row["deals_count"]), + "median_6m": ( + float(mp_row["median_6m"]) if mp_row["median_6m"] is not None else None + ), + "median_12m": ( + float(mp_row["median_12m"]) if mp_row["median_12m"] is not None else None + ), + "median_24m": ( + float(mp_row["median_24m"]) if mp_row["median_24m"] is not None else None + ), + "last_deal_date": ( + mp_row["last_deal_date"].isoformat() + if mp_row["last_deal_date"] is not None + else None + ), + "source": "quarter_mv", + } + else: + market_price = {"deals_count": 0, "source": "no_data"} + except Exception as e: + logger.warning("market_price query failed for %s: %s", cad_num, e) + market_price = {"deals_count": 0, "source": "no_data"} + # 10d) Geology stub — реальные данные требуют ВСЕГЕИ-200/1000 шейпы в PostGIS karpinsky_url = ( f"https://www.karpinskyinstitute.ru/ru/gisatlas/web-gisatlas/" @@ -1935,6 +1984,8 @@ def analyze_parcel( # P2 (#46) — соседи-здания + overlap check "neighbors_summary": _neighbors_summary(db, geom_wkt, cad_num), "market_trend": market_trend, + # #33 D2: квартальная ценовая статистика из mv_quarter_price_per_m2 + "market_price": market_price, "zoning": zoning, "success_recommendation": success_recommendation, "isochrones_available": bool(settings.openrouteservice_api_key), diff --git a/backend/app/schemas/parcel.py b/backend/app/schemas/parcel.py index 851904cd..1f67c09a 100644 --- a/backend/app/schemas/parcel.py +++ b/backend/app/schemas/parcel.py @@ -215,6 +215,28 @@ class BestLayoutsResponse(BaseModel): data_quality: LayoutDataQuality +# ── Analyze endpoint market price (#33) ────────────────────────────────────── + + +class MarketPrice(BaseModel): + """Ценовая статистика квартала из mv_quarter_price_per_m2 (Issue #33). + + Источник: rosreestr_deals, фильтр realestate_type_code='002001003000' (ДДУ), + скользящее окно 24 мес., 30K–800K руб/м², HAVING >= 3 сделок. + """ + + p25: float | None = None + median: float | None = None + p75: float | None = None + mean: float | None = None + deals_count: int = 0 + median_6m: float | None = None + median_12m: float | None = None + median_24m: float | None = None + last_deal_date: str | None = None # ISO date + source: Literal["quarter_mv", "no_data"] = "no_data" + + # ── Analyze endpoint inline weights (#201) ──────────────────────────────────── diff --git a/backend/tests/api/v1/test_analyze_market_price.py b/backend/tests/api/v1/test_analyze_market_price.py new file mode 100644 index 00000000..4b44242f --- /dev/null +++ b/backend/tests/api/v1/test_analyze_market_price.py @@ -0,0 +1,263 @@ +"""Тесты для market_price в POST /api/v1/parcels/{cad_num}/analyze (#33). + +Покрывает: +1. analyze с known quarter в mv_quarter_price_per_m2 → возвращает median/p25/p75/source='quarter_mv' +2. analyze с quarter которого нет в MV → deals_count=0, source='no_data' +3. analyze с invalid cad → 404 (no regression) + +Стратегия mock: аналогична test_analyze_inline_weights.py — DB mock через +dependency_overrides, тяжёлые сервисы патчим через unittest.mock.patch. + +Порядок db.execute calls в analyze_parcel (v3.7 + #33): + 0. UNION ALL geom + source → .mappings().first() + 1. WKT query → .mappings().first() + 2. District → .mappings().first() + 3. POI rows → .mappings().all() + 4. Competitor rows → .mappings().all() + 5. Pipeline rows → .mappings().all() + 6. Centroid lat/lon → .mappings().first() + 7. Noise rows → .mappings().all() + 8. Hydrology → .mappings().all() + 9. Utilities → .mappings().all() + 10. Market trend → .mappings().first() + 11. Zoning (begin_nested) → .mappings().first() + 12. Success recommendation (begin_nested) → .mappings().all() + 13. Market price (begin_nested) → .mappings().first() ← NEW #33 + 14. _geotech_risk (industrial count) → .scalar() + 15. _neighbors_summary (neighbor_rows) → .mappings().all() + 16. _neighbors_summary (overlap_row) → .mappings().first() +""" + +from __future__ import annotations + +import datetime as dt +from decimal import Decimal +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +from app.main import app + +# ── Константы ───────────────────────────────────────────────────────────────── + +_CAD = "66:41:0204016:10" # cad_num с 4 частями — quarter = "66:41:0204016" +_CAD_3PARTS = "66:41:0204016" # cad_num уже является quarter +_WKT = "POLYGON((60.6 56.838, 60.61 56.838, 60.61 56.845, 60.6 56.845, 60.6 56.838))" +_GEOJSON = '{"type":"Polygon","coordinates":[[[60.6,56.838],[60.61,56.838]]]}' + +# ── Mock factories ───────────────────────────────────────────────────────────── + + +def _make_mapping(data: dict[str, Any]) -> MagicMock: + """Создать mock-строку (mapping) с __getitem__ + .get() для dict-like доступа.""" + m = MagicMock() + m.__getitem__ = lambda self, k: data[k] + m.get = lambda k, default=None: data.get(k, default) + return m + + +def _make_db_for_analyze( + geom_found: bool = True, + district_found: bool = True, + market_price_row: dict[str, Any] | None = None, +) -> MagicMock: + """Сконструировать mock DB Session для analyze_parcel. + + market_price_row=None → имитирует "нет данных в MV" (mp_row is None → source='no_data'). + market_price_row={...} → имитирует найденную строку в MV. + """ + db = MagicMock() + + geom_row = ( + _make_mapping({"geom_geojson": _GEOJSON, "geom_wkb": None, "source": "cad_quarter"}) + if geom_found + else None + ) + wkt_row = _make_mapping({"wkt": _WKT}) if geom_found else None + district_row = ( + _make_mapping( + { + "district_name": "Октябрьский", + "median_price_per_m2": 120000, + "dist_to_center": 1500.0, + } + ) + if district_found + else None + ) + centroid_row = _make_mapping({"lat": 56.84, "lon": 60.605}) + + mp_mock = _make_mapping(market_price_row) if market_price_row is not None else None + + call_idx = [0] + responses: list[Any] = [ + ("first", geom_row), # 0: geom UNION ALL + ("first", wkt_row), # 1: WKT + ("first", district_row), # 2: district + ("all", []), # 3: POI rows + ("all", []), # 4: competitor rows + ("all", []), # 5: pipeline rows + ("first", centroid_row), # 6: centroid + ("all", []), # 7: noise rows + ("all", []), # 8: hydrology rows + ("all", []), # 9: utilities rows + ("first", None), # 10: market trend + ("first", None), # 11: zoning (begin_nested) + ("all", []), # 12: success recommendation (begin_nested) + ("first", mp_mock), # 13: market price (begin_nested) ← #33 + ("scalar", 0), # 14: geotech_risk + ("all", []), # 15: neighbors + ("first", None), # 16: overlap + ] + + def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock: + idx = call_idx[0] + call_idx[0] += 1 + if idx >= len(responses): + r = MagicMock() + r.mappings.return_value.first.return_value = None + r.mappings.return_value.all.return_value = [] + r.scalar.return_value = 0 + return r + kind, data = responses[idx] + r = MagicMock() + r.mappings.return_value.first.return_value = data + r.mappings.return_value.all.return_value = data if isinstance(data, list) else [] + r.scalar.return_value = data if kind == "scalar" else 0 + return r + + db.execute.side_effect = _execute_side_effect + + ctx = MagicMock() + ctx.__enter__ = MagicMock(return_value=ctx) + ctx.__exit__ = MagicMock(return_value=False) + db.begin_nested.return_value = ctx + + return db + + +def _override_db(db: MagicMock): + def _get_db_override(): + yield db + + return _get_db_override + + +_PATCHES = [ + patch("app.api.v1.parcels._fetch_air_quality_sync", return_value=None), + patch("app.api.v1.parcels._fetch_weather_sync", return_value=None), + patch("app.api.v1.parcels._fetch_seasonal_weather_sync", return_value=None), + patch( + "app.api.v1.parcels.get_quarter_dump_data", + return_value={ + "nspd_zoning": None, + "nspd_zouit_overlaps": [], + "nspd_engineering_nearby": [], + "nspd_dump": {"available": False, "stale": False, "harvest_triggered": False}, + }, + ), + patch("app.api.v1.parcels.compute_velocity", return_value=None), + patch("app.api.v1.parcels.compute_gate_verdict", return_value={"verdict": "unknown"}), +] + + +def _start_patches() -> None: + for p in _PATCHES: + p.start() + + +def _stop_patches() -> None: + for p in _PATCHES: + p.stop() + + +# ── Тесты ───────────────────────────────────────────────────────────────────── + + +def test_market_price_found_in_mv() -> None: + """analyze с known quarter → market_price содержит median/p25/p75, source='quarter_mv'.""" + from app.core.db import get_db + + mv_data: dict[str, Any] = { + "p25": Decimal("85000.00"), + "median": Decimal("102000.00"), + "p75": Decimal("118000.00"), + "mean": Decimal("103500.00"), + "deals_count": 47, + "median_6m": Decimal("105000.00"), + "median_12m": Decimal("100000.00"), + "median_24m": Decimal("102000.00"), + "last_deal_date": dt.date(2026, 3, 15), + } + db = _make_db_for_analyze(market_price_row=mv_data) + app.dependency_overrides[get_db] = _override_db(db) + _start_patches() + try: + client = TestClient(app) + resp = client.post(f"/api/v1/parcels/{_CAD}/analyze") + assert resp.status_code == 200, resp.text + body = resp.json() + assert "market_price" in body, "market_price отсутствует в ответе" + mp = body["market_price"] + assert mp["source"] == "quarter_mv" + assert mp["deals_count"] == 47 + assert mp["median"] == pytest.approx(102000.0) + assert mp["p25"] == pytest.approx(85000.0) + assert mp["p75"] == pytest.approx(118000.0) + assert mp["mean"] == pytest.approx(103500.0) + assert mp["median_6m"] == pytest.approx(105000.0) + assert mp["median_12m"] == pytest.approx(100000.0) + assert mp["median_24m"] == pytest.approx(102000.0) + assert mp["last_deal_date"] == "2026-03-15" + finally: + app.dependency_overrides.clear() + _stop_patches() + + +def test_market_price_quarter_not_in_mv() -> None: + """analyze с quarter которого нет в MV → deals_count=0, source='no_data'.""" + from app.core.db import get_db + + # market_price_row=None → mp_mock=None → ветка else {"deals_count": 0, "source": "no_data"} + db = _make_db_for_analyze(market_price_row=None) + app.dependency_overrides[get_db] = _override_db(db) + _start_patches() + try: + client = TestClient(app) + resp = client.post(f"/api/v1/parcels/{_CAD}/analyze") + assert resp.status_code == 200, resp.text + body = resp.json() + assert "market_price" in body + mp = body["market_price"] + assert mp["source"] == "no_data" + assert mp["deals_count"] == 0 + # price fields absent или None + assert mp.get("median") is None + assert mp.get("p25") is None + finally: + app.dependency_overrides.clear() + _stop_patches() + + +def test_market_price_invalid_cad_returns_404() -> None: + """analyze с несуществующим cad → 404 (no regression).""" + from app.core.db import get_db + + # geom_found=False → geom_row=None → endpoint вернёт 202 (inline fetch) или 404 + # В тестовой среде on-demand fetch задизейблен (нет Celery/Redis) → 404 expected + db = _make_db_for_analyze(geom_found=False) + app.dependency_overrides[get_db] = _override_db(db) + _start_patches() + try: + client = TestClient(app) + resp = client.post("/api/v1/parcels/00:00:0000000:0/analyze") + # При отсутствии геометрии возвращается 202 (on-demand fetch enqueue) или 404 + assert resp.status_code in ( + 202, + 404, + ), f"Ожидали 202 или 404 для неизвестного cad, получили {resp.status_code}: {resp.text}" + finally: + app.dependency_overrides.clear() + _stop_patches() From adc45f57243b379679a1ae9ca4a16524eb0b737b Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 16 May 2026 11:41:21 +0000 Subject: [PATCH 013/342] =?UTF-8?q?feat(permits):=20=D0=95=D0=9A=D0=91=20?= =?UTF-8?q?=D0=A0=D0=9D=D0=A1/=D0=A0=D0=92=D0=AD=20xlsx=20parser=20+=20sch?= =?UTF-8?q?ema=20+=20ETL=20(#105=20PR=20A)=20(#211)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/services/scrapers/ekburg_permits.py | 346 ++++++++++++++++++ backend/app/workers/celery_app.py | 1 + .../app/workers/tasks/ekburg_permits_sync.py | 151 ++++++++ backend/tests/services/test_ekburg_permits.py | 339 +++++++++++++++++ data/sql/96_ekburg_construction_permits.sql | 91 +++++ 5 files changed, 928 insertions(+) create mode 100644 backend/app/services/scrapers/ekburg_permits.py create mode 100644 backend/app/workers/tasks/ekburg_permits_sync.py create mode 100644 backend/tests/services/test_ekburg_permits.py create mode 100644 data/sql/96_ekburg_construction_permits.sql diff --git a/backend/app/services/scrapers/ekburg_permits.py b/backend/app/services/scrapers/ekburg_permits.py new file mode 100644 index 00000000..6cf3387f --- /dev/null +++ b/backend/app/services/scrapers/ekburg_permits.py @@ -0,0 +1,346 @@ +"""ЕКБ РНС/РВЭ xlsx parser (Issue #105). + +Source: https://xn--80acgfbsl1azdqr.xn--p1ai/дляработы/гиз/градостроительство/разрешение +Format: Form 3 (РНС) + Form 4 (РВЭ) — стандартные Росстат шаблоны. + +Структура xlsx (проверена на всех файлах 2022-2026): + Лист «реестр разрешений на строительс» → РНС (Таблица 3) + Лист «реестр разрешений на ввод» → РВЭ (Таблица 4) + +Расположение заголовков: + 2022: строки 5-7 — заголовок/подзаголовок/номера; данные с row 8 + 2023+: строки 4-6 — заголовок/подзаголовок/номера; данные с row 7 + +Колонки (0-based) одинаковы во всех годах: + 0 developer_name — Наименование застройщика + 1 developer_inn — ИНН (int или str) + 2 developer_address — Адрес застройщика (не сохраняем) + 3 object_type — Тип строительного объекта + 4 object_name — Наименование объекта КС + 5 cadastral_number — Кадастровый номер ЗУ + 6 raw_coord_x — X (СКФ-66, местная СК Свердловской обл.) + 7 raw_coord_y — Y (СКФ-66) + 8 construction_address — Адрес объекта + 9 permit_number — Номер разрешения на строительство + 10 issue_date — Дата разрешения на строительство + 11 expiry_date — Дата окончания разрешения + 12 total_area_sqm — Общая площадь, м² + 13 living_area_sqm — Площадь жилых помещений по проекту, м² + -- дополнительно только для РВЭ: + 14 living_area_fact_sqm — Площадь жилых помещений фактически, м² + 15 rve_number — Номер разрешения на ввод + 16 rve_date — Дата разрешения на ввод + +Координаты raw_coord_x / raw_coord_y хранятся как строки — CRS неизвестна (предположительно +СНСК-66 / СКФ-66 Свердловская обл., local offset system). Геокодирование через construction_address +планируется в Phase 3 (отдельный PR). +""" + +from __future__ import annotations + +import logging +import re +from collections.abc import Iterator +from dataclasses import dataclass, field +from datetime import date, datetime +from io import BytesIO +from typing import Any + +import httpx +from openpyxl import load_workbook + +logger = logging.getLogger(__name__) + +EKBURG_PERMITS_URLS: dict[int, str] = { + 2026: "https://xn--80acgfbsl1azdqr.xn--p1ai/file/70bf01bf31538ee9dd82dadfc47192a0", + 2025: "https://xn--80acgfbsl1azdqr.xn--p1ai/file/6a0a18c9ee327e6e4f76c32a5385a6bd", + 2024: "https://xn--80acgfbsl1azdqr.xn--p1ai/file/907bfa0cf78d5a93c6ccafed1af51fc5", + 2023: "https://xn--80acgfbsl1azdqr.xn--p1ai/file/3b2ef86bf5673adaa65263672f0c623f", + 2022: "https://xn--80acgfbsl1azdqr.xn--p1ai/file/51a7e5654d2fa018bc6db402ab4d5775", +} + +# Листы которые нужно пропустить (справочник, вспомогательные) +_SKIP_SHEETS = {"справочник", "лист1", "sheet1"} + +# Паттерн для проверки что строка — строка данных (первая ячейка непустая и не число-нумерация) +_INN_RE = re.compile(r"\d{10,12}") + + +@dataclass +class PermitRow: + """Parsed row from РНС/РВЭ xlsx.""" + + permit_type: str # "RNS" | "RVE" + permit_number: str + issue_date: date | None + expiry_date: date | None + developer_inn: str | None + developer_name: str | None + object_name: str | None + object_type: str | None + construction_address: str | None + cadastral_number: str | None + total_area_sqm: float | None + living_area_sqm: float | None + living_area_fact_sqm: float | None # только РВЭ + rve_number: str | None # только РВЭ + rve_date: date | None # только РВЭ + raw_coord_x: str | None + raw_coord_y: str | None + source_year: int + source_url: str + raw_row: dict[str, Any] = field(default_factory=dict) + + +# ── helpers ────────────────────────────────────────────────────────────────── + + +def _to_str(v: Any) -> str | None: + """Привести значение ячейки к строке, убрать лишние пробелы. None если пусто.""" + if v is None: + return None + s = str(v).strip().replace("\xa0", " ") + return s if s and s != "-" else None + + +def _to_date(v: Any) -> date | None: + """Привести значение ячейки к date. + + Принимает: datetime (openpyxl), date, строки 'DD.MM.YYYY', 'YYYY-MM-DD'. + """ + if v is None: + return None + if isinstance(v, datetime): + return v.date() + if isinstance(v, date): + return v + if isinstance(v, str): + s = v.strip() + for fmt in ("%d.%m.%Y", "%Y-%m-%d", "%d-%m-%Y"): + try: + return datetime.strptime(s, fmt).date() + except ValueError: + continue + return None + + +def _to_float(v: Any) -> float | None: + """Привести значение ячейки к float. Обрабатывает запятую как разделитель.""" + if v is None: + return None + if isinstance(v, int | float): + return float(v) if not isinstance(v, bool) else None + s = str(v).strip().replace(",", ".").replace("\xa0", "").replace(" ", "") + if not s or s == "-": + return None + try: + return float(s) + except ValueError: + return None + + +def _clean_inn(v: Any) -> str | None: + """Извлечь ИНН из значения ячейки. ИНН — 10 или 12 цифр.""" + if v is None: + return None + s = str(v).strip().replace("\xa0", "") + # Если ячейка — число (openpyxl выдаёт int/float) + if isinstance(v, int | float) and not isinstance(v, bool): + digits = str(int(v)) + if 10 <= len(digits) <= 12: + return digits + return None + m = _INN_RE.search(s) + return m.group(0) if m else None + + +def _detect_permit_type(sheet_name: str) -> str | None: + """Определить тип разрешения по имени листа.""" + name = sheet_name.lower() + if "строит" in name: + return "RNS" + if "ввод" in name: + return "RVE" + return None + + +def _detect_header_row(sheet: Any) -> int: + """Найти строку с заголовком 'Наименование застройщика' (1-based). + + Возвращает номер строки-данных (header_row + 3, так как за заголовком идут + две строки подзаголовков и нумерация). + """ + for row_idx, row in enumerate( + sheet.iter_rows(min_row=1, max_row=10, values_only=True), start=1 + ): + if row and row[0] and "застройщик" in str(row[0]).lower(): + # +3: подзаголовок (X/Y), нумерация (1,2,3...), первая данных + return row_idx + 3 + # Fallback: стандартные позиции + # 2022: header=5, данные с 8; 2023+: header=4, данные с 7 + return 7 + + +def _is_data_row(row: tuple[Any, ...]) -> bool: + """Вернуть True если строка содержит реальные данные (не пустая, не заголовок).""" + if not row or all(v is None for v in row): + return False + first = row[0] + if first is None: + # Продолжение предыдущей записи (merged cells) — пропускаем + return False + # Проверяем, что первая ячейка — не номер (нумерация столбцов в header) + if isinstance(first, int | float) and not isinstance(first, bool): + val = int(first) + if 1 <= val <= 30: + return False + return True + + +def _parse_row( + row: tuple[Any, ...], + permit_type: str, + year: int, + source_url: str, +) -> PermitRow | None: + """Распарсить одну строку данных в PermitRow. + + Возвращает None если строка не содержит номера разрешения (обязательное поле). + """ + # permit_number — колонка 9 (0-based) + permit_number = _to_str(row[9]) if len(row) > 9 else None + if not permit_number: + return None + + raw: dict[str, Any] = {str(i): str(v) for i, v in enumerate(row) if v is not None} + + return PermitRow( + permit_type=permit_type, + permit_number=permit_number, + issue_date=_to_date(row[10]) if len(row) > 10 else None, + expiry_date=_to_date(row[11]) if len(row) > 11 else None, + developer_inn=_clean_inn(row[1]) if len(row) > 1 else None, + developer_name=_to_str(row[0]) if len(row) > 0 else None, + object_name=_to_str(row[4]) if len(row) > 4 else None, + object_type=_to_str(row[3]) if len(row) > 3 else None, + construction_address=_to_str(row[8]) if len(row) > 8 else None, + cadastral_number=_to_str(row[5]) if len(row) > 5 else None, + total_area_sqm=_to_float(row[12]) if len(row) > 12 else None, + living_area_sqm=_to_float(row[13]) if len(row) > 13 else None, + living_area_fact_sqm=_to_float(row[14]) if permit_type == "RVE" and len(row) > 14 else None, + rve_number=_to_str(row[15]) if permit_type == "RVE" and len(row) > 15 else None, + rve_date=_to_date(row[16]) if permit_type == "RVE" and len(row) > 16 else None, + raw_coord_x=_to_str(row[6]) if len(row) > 6 else None, + raw_coord_y=_to_str(row[7]) if len(row) > 7 else None, + source_year=year, + source_url=source_url, + raw_row=raw, + ) + + +# ── client ─────────────────────────────────────────────────────────────────── + + +class EkburgPermitsClient: + """Client для загрузки + парсинга РНС/РВЭ xlsx с екатеринбург.рф.""" + + DEFAULT_TIMEOUT = 60.0 + USER_AGENT = "GenDesign/1.0 (+https://gendsgn.ru) Site Finder permits scraper" + + def __init__(self, *, timeout: float = DEFAULT_TIMEOUT) -> None: + self._client = httpx.Client( + timeout=timeout, + follow_redirects=True, + headers={"User-Agent": self.USER_AGENT}, + ) + + def __enter__(self) -> EkburgPermitsClient: + return self + + def __exit__(self, *_: Any) -> None: + self._client.close() + + def download_xlsx(self, year: int) -> bytes: + """GET xlsx для заданного года. Поднимает ValueError для неизвестного года.""" + url = EKBURG_PERMITS_URLS.get(year) + if not url: + raise ValueError(f"No URL configured for year={year}") + response = self._client.get(url) + response.raise_for_status() + logger.info("Downloaded ekburg permits xlsx %d: %d bytes", year, len(response.content)) + return response.content + + def parse_xlsx(self, content: bytes, year: int, source_url: str) -> Iterator[PermitRow]: + """Parse xlsx bytes → yield PermitRow. + + Автоматически определяет тип листа (РНС/РВЭ) по названию. + Пропускает листы «Справочник», «Лист1» и неизвестные. + """ + wb = load_workbook(BytesIO(content), read_only=True, data_only=True) + + for sheet_name in wb.sheetnames: + if sheet_name.lower() in _SKIP_SHEETS: + continue + + permit_type = _detect_permit_type(sheet_name) + if permit_type is None: + logger.debug("Skipping unknown sheet %r in year %d", sheet_name, year) + continue + + sheet = wb[sheet_name] + data_start = _detect_header_row(sheet) + logger.info( + "Parsing sheet %r (%s) year=%d, data starts at row %d", + sheet_name, + permit_type, + year, + data_start, + ) + yield from self._parse_sheet(sheet, permit_type, year, source_url, data_start) + + def _parse_sheet( + self, + sheet: Any, + permit_type: str, + year: int, + source_url: str, + data_start: int, + ) -> Iterator[PermitRow]: + """Parse один лист → yield PermitRow per data row.""" + row_count = 0 + skip_count = 0 + + for row_idx, row in enumerate( + sheet.iter_rows(min_row=data_start, values_only=True), start=data_start + ): + if not _is_data_row(row): + skip_count += 1 + continue + + try: + permit = _parse_row(row, permit_type, year, source_url) + except Exception as exc: + logger.warning( + "Failed to parse row %d sheet %s year %d: %s", + row_idx, + permit_type, + year, + exc, + ) + skip_count += 1 + continue + + if permit is None: + skip_count += 1 + continue + + row_count += 1 + yield permit + + logger.info( + "Sheet %s year=%d: parsed=%d skipped=%d", + permit_type, + year, + row_count, + skip_count, + ) diff --git a/backend/app/workers/celery_app.py b/backend/app/workers/celery_app.py index 9bf49e43..4afd7107 100644 --- a/backend/app/workers/celery_app.py +++ b/backend/app/workers/celery_app.py @@ -27,6 +27,7 @@ celery_app = Celery( "app.workers.tasks.noise_sync", "app.workers.tasks.pzz_sync", "app.workers.tasks.scrape_cadastre", + "app.workers.tasks.ekburg_permits_sync", ], ) celery_app.conf.timezone = "Europe/Moscow" diff --git a/backend/app/workers/tasks/ekburg_permits_sync.py b/backend/app/workers/tasks/ekburg_permits_sync.py new file mode 100644 index 00000000..9f9d6fc2 --- /dev/null +++ b/backend/app/workers/tasks/ekburg_permits_sync.py @@ -0,0 +1,151 @@ +"""Celery task: monthly refresh ekburg permits xlsx (Issue #105). + +Запускается через beat (1-е число каждого месяца в 05:00 МСК). +Добавить в beat_schedule через job_settings или hardcoded entry (Phase 2 followup). +""" + +from __future__ import annotations + +import json +import logging + +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.core.db import SessionLocal +from app.services.scrapers.ekburg_permits import ( + EKBURG_PERMITS_URLS, + EkburgPermitsClient, + PermitRow, +) +from app.workers.celery_app import celery_app + +logger = logging.getLogger(__name__) + + +def _upsert_permit(db: Session, row: PermitRow) -> None: + """UPSERT одной строки разрешения в ekburg_construction_permits.""" + db.execute( + text(""" + INSERT INTO ekburg_construction_permits ( + permit_type, permit_number, + issue_date, expiry_date, + developer_inn, developer_name, + object_name, object_type, + construction_address, cadastral_number, + total_area_sqm, living_area_sqm, living_area_fact_sqm, + rve_number, rve_date, + raw_coord_x, raw_coord_y, + source_year, source_url, raw_row + ) + VALUES ( + :permit_type, :permit_number, + :issue_date, :expiry_date, + :developer_inn, :developer_name, + :object_name, :object_type, + :construction_address, :cadastral_number, + :total_area_sqm, :living_area_sqm, :living_area_fact_sqm, + :rve_number, :rve_date, + :raw_coord_x, :raw_coord_y, + :source_year, :source_url, CAST(:raw_row AS jsonb) + ) + ON CONFLICT (permit_type, permit_number) DO UPDATE SET + issue_date = EXCLUDED.issue_date, + expiry_date = EXCLUDED.expiry_date, + developer_inn = EXCLUDED.developer_inn, + developer_name = EXCLUDED.developer_name, + object_name = EXCLUDED.object_name, + object_type = EXCLUDED.object_type, + construction_address = EXCLUDED.construction_address, + cadastral_number = EXCLUDED.cadastral_number, + total_area_sqm = EXCLUDED.total_area_sqm, + living_area_sqm = EXCLUDED.living_area_sqm, + living_area_fact_sqm = EXCLUDED.living_area_fact_sqm, + rve_number = EXCLUDED.rve_number, + rve_date = EXCLUDED.rve_date, + raw_coord_x = EXCLUDED.raw_coord_x, + raw_coord_y = EXCLUDED.raw_coord_y, + raw_row = EXCLUDED.raw_row, + fetched_at = NOW() + """), + { + "permit_type": row.permit_type, + "permit_number": row.permit_number, + "issue_date": row.issue_date, + "expiry_date": row.expiry_date, + "developer_inn": row.developer_inn, + "developer_name": row.developer_name, + "object_name": row.object_name, + "object_type": row.object_type, + "construction_address": row.construction_address, + "cadastral_number": row.cadastral_number, + "total_area_sqm": row.total_area_sqm, + "living_area_sqm": row.living_area_sqm, + "living_area_fact_sqm": row.living_area_fact_sqm, + "rve_number": row.rve_number, + "rve_date": row.rve_date, + "raw_coord_x": row.raw_coord_x, + "raw_coord_y": row.raw_coord_y, + "source_year": row.source_year, + "source_url": row.source_url, + "raw_row": json.dumps(row.raw_row, ensure_ascii=False), + }, + ) + + +@celery_app.task(name="tasks.ekburg_permits_sync.refresh_year", queue="celery") +def refresh_year(year: int) -> dict[str, int]: + """Скачать + распарсить + upsert РНС/РВЭ за один год. + + Возвращает {"inserted": N, "errors": N}. + """ + inserted = 0 + errors = 0 + + url = EKBURG_PERMITS_URLS.get(year) + if not url: + logger.error("ekburg_permits_sync: no URL for year=%d", year) + return {"inserted": 0, "errors": 1} + + with EkburgPermitsClient() as client: + try: + content = client.download_xlsx(year) + except Exception as exc: + logger.error("ekburg_permits_sync: download failed year=%d: %s", year, exc) + return {"inserted": 0, "errors": 1} + + with SessionLocal() as db: + for row in client.parse_xlsx(content, year, url): + try: + with db.begin_nested(): + _upsert_permit(db, row) + inserted += 1 + except Exception as exc: + logger.warning( + "ekburg_permits_sync: upsert failed %s/%s year=%d: %s", + row.permit_type, + row.permit_number, + year, + exc, + ) + errors += 1 + db.commit() + + logger.info( + "ekburg_permits_sync: year=%d done inserted=%d errors=%d", + year, + inserted, + errors, + ) + return {"inserted": inserted, "errors": errors} + + +@celery_app.task(name="tasks.ekburg_permits_sync.refresh_all", queue="celery") +def refresh_all() -> dict[str, dict[str, int]]: + """Обновить все 5 лет (2022-2026). Планируется через Celery beat ежемесячно.""" + results: dict[str, dict[str, int]] = {} + for year in sorted(EKBURG_PERMITS_URLS.keys()): + logger.info("ekburg_permits_sync: starting year=%d", year) + results[str(year)] = refresh_year(year) + logger.info("ekburg_permits_sync: refresh_all done: %s", results) + return results diff --git a/backend/tests/services/test_ekburg_permits.py b/backend/tests/services/test_ekburg_permits.py new file mode 100644 index 00000000..74738d55 --- /dev/null +++ b/backend/tests/services/test_ekburg_permits.py @@ -0,0 +1,339 @@ +"""Тесты для ekburg_permits.py (Issue #105). + +Использует mock httpx + синтетические данные xlsx (openpyxl в памяти). +Не требует реального PostgreSQL или сетевого доступа. +""" + +from __future__ import annotations + +from datetime import date, datetime +from io import BytesIO +from typing import Any +from unittest.mock import MagicMock + +import pytest +from openpyxl import Workbook + +from app.services.scrapers.ekburg_permits import ( + EKBURG_PERMITS_URLS, + EkburgPermitsClient, + _clean_inn, + _detect_permit_type, + _to_date, + _to_float, + _to_str, +) + +# ── helpers ─────────────────────────────────────────────────────────────────── + + +def _make_xlsx_bytes(sheets: dict[str, list[tuple[Any, ...]]]) -> bytes: + """Создать xlsx в памяти с заданными листами и строками.""" + wb = Workbook() + wb.remove(wb.active) # удалить дефолтный лист + for sheet_name, rows in sheets.items(): + ws = wb.create_sheet(sheet_name) + for row in rows: + ws.append(list(row)) + buf = BytesIO() + wb.save(buf) + return buf.getvalue() + + +def _rns_sheet_rows() -> list[tuple[Any, ...]]: + """Синтетические строки для листа РНС (Form 3). + + Воспроизводит структуру 2024-2026: header row 4, данные с row 7. + """ + return [ + # row 1: пусто + (None,) * 14, + # row 2: заголовок таблицы + ( + "Таблица 3. Реестр выданных разрешений на строительство " + "объектов капитального строительства", + ) + + (None,) * 13, + # row 3: пусто + (None,) * 14, + # row 4: заголовки колонок + ( + "Наименование застройщика", + "ИНН", + "Адрес застройщика", + "Тип строительного объекта1", + "Наименование объекта КС", + "Кадастровый номер ЗУ", + "Координаты X", + None, + "Адрес объекта", + "Реквизиты разрешения (номер)", + None, + "Дата окончания", + "Общая площадь, м2", + "Площадь жилых помещений по проекту, м2", + ), + # row 5: подзаголовки + (None,) * 7 + ("X", "Y") + (None,) * 3 + ("номер", "дата") + (None,) * 2, + # row 6: нумерация столбцов + tuple(range(1, 15)), + # row 7: первая строка данных (МКД — ООО "Тест") + ( + 'ООО "Специализированный застройщик "Тест"', + 6685000001, + "620000, г. Екатеринбург, ул. Тестовая, д. 1", + "многоквартирные жилые дома;", + "Многоквартирный жилой дом (№ 1 по ПЗУ)", + "66:41:0101001:123", + "1530000.0000", + "380000.0000", + "Свердловская обл., г. Екатеринбург, ул. Тестовая", + "66-41-99-2024", + datetime(2024, 3, 15), + datetime(2027, 3, 15), + 12345.67, + 8000.0, + ), + # row 8: строка без permit_number (должна быть пропущена) + ( + "ООО «Продолжение»", + 6685000002, + "620001, г. Екатеринбург", + "производственные здания;", + "Производственный корпус", + "66:41:0101002:456", + "1531000.0000", + "381000.0000", + "Свердловская обл., г. Екатеринбург, ул. Другая", + None, # no permit_number → skip + datetime(2024, 5, 20), + datetime(2026, 5, 20), + 500.0, + None, + ), + # row 9: empty row (all None) — should be skipped + (None,) * 14, + ] + + +def _rve_sheet_rows() -> list[tuple[Any, ...]]: + """Синтетические строки для листа РВЭ (Form 4).""" + return [ + (None,) * 19, + ("Таблица 4. Реестр выданных разрешений на ввод в эксплуатацию",) + (None,) * 18, + (None,) * 19, + # row 4: заголовки + ( + "Наименование застройщика", + "ИНН", + "Адрес застройщика", + "Тип строительного объекта1", + "Наименование объекта КС", + "Кадастровый номер ЗУ", + "Координаты X", + None, + "Адрес объекта", + "Реквизиты РНС (номер)", + None, + "Дата окончания РНС", + "Общая площадь, м2", + "Площадь жилых по проекту, м2", + "Площадь жилых фактически, м2", + "Реквизиты РВЭ (номер)", + None, + "Введённые мощности", + None, + ), + (None,) * 7 + ("X", "Y") + (None,) * 5 + ("номер", "дата") + (None,) * 4, + tuple(range(1, 19)), + # row 7: первая строка данных + ( + 'ООО "Застройщик Ввода"', + 6658000002, + "620100, г. Екатеринбург, ул. Вводная, д. 2", + "многоквартирные жилые дома;", + "Жилой дом (1 очередь) (№ 1 по ПЗУ)", + "66:41:0303001:789", + "1535000.0000", + "385000.0000", + "Свердловская обл., г. Екатеринбург, ул. Вводная", + "66-41-50-2022", + datetime(2022, 10, 1), + datetime(2026, 1, 1), + 30000.0, + 20000.0, + 19800.5, # living_area_fact + "66-41-5-2026", + datetime(2026, 1, 13), + None, + None, + ), + ] + + +# ── unit tests: helpers ─────────────────────────────────────────────────────── + + +class TestHelpers: + def test_to_date_datetime(self) -> None: + dt = datetime(2024, 3, 15, 0, 0) + assert _to_date(dt) == date(2024, 3, 15) + + def test_to_date_string_dot(self) -> None: + assert _to_date("15.03.2024") == date(2024, 3, 15) + + def test_to_date_none(self) -> None: + assert _to_date(None) is None + + def test_to_date_invalid(self) -> None: + assert _to_date("not-a-date") is None + + def test_to_float_comma(self) -> None: + assert _to_float("12345,67") == pytest.approx(12345.67) + + def test_to_float_dash(self) -> None: + assert _to_float("-") is None + + def test_to_float_int(self) -> None: + assert _to_float(1490) == pytest.approx(1490.0) + + def test_to_str_nbsp(self) -> None: + assert _to_str("г.\xa0Екатеринбург") == "г. Екатеринбург" + + def test_to_str_none(self) -> None: + assert _to_str(None) is None + + def test_clean_inn_int(self) -> None: + assert _clean_inn(6685180480) == "6685180480" + + def test_clean_inn_float(self) -> None: + # openpyxl иногда выдаёт float для длинных чисел + assert _clean_inn(6685180480.0) == "6685180480" + + def test_clean_inn_string(self) -> None: + assert _clean_inn("6685180480") == "6685180480" + + def test_clean_inn_none(self) -> None: + assert _clean_inn(None) is None + + def test_clean_inn_short(self) -> None: + # 9 цифр — не ИНН + assert _clean_inn(123456789) is None + + def test_detect_permit_type_rns(self) -> None: + assert _detect_permit_type("реестр разрешений на строительс") == "RNS" + + def test_detect_permit_type_rve(self) -> None: + assert _detect_permit_type("реестр разрешений на ввод") == "RVE" + + def test_detect_permit_type_unknown(self) -> None: + assert _detect_permit_type("Справочник") is None + + +# ── unit tests: xlsx parsing ────────────────────────────────────────────────── + + +class TestParseXlsx: + def _make_client(self) -> EkburgPermitsClient: + client = EkburgPermitsClient.__new__(EkburgPermitsClient) + # Создаём mock httpx.Client чтобы не открывать реальные соединения + client._client = MagicMock() + return client + + def test_parse_rns_yields_permit_row(self) -> None: + content = _make_xlsx_bytes( + { + "реестр разрешений на строительс": _rns_sheet_rows(), + "Справочник": [("многоквартирные жилые дома;",)], + } + ) + client = self._make_client() + rows = list(client.parse_xlsx(content, 2024, EKBURG_PERMITS_URLS[2024])) + + # Должна быть ровно одна строка (вторая без permit_number пропущена) + assert len(rows) == 1 + row = rows[0] + assert row.permit_type == "RNS" + assert row.permit_number == "66-41-99-2024" + assert row.issue_date == date(2024, 3, 15) + assert row.expiry_date == date(2027, 3, 15) + assert row.developer_inn == "6685000001" + assert row.object_type == "многоквартирные жилые дома;" + assert row.total_area_sqm == pytest.approx(12345.67) + assert row.living_area_sqm == pytest.approx(8000.0) + assert row.source_year == 2024 + + def test_parse_rve_yields_permit_row_with_rve_fields(self) -> None: + content = _make_xlsx_bytes({"реестр разрешений на ввод": _rve_sheet_rows()}) + client = self._make_client() + rows = list(client.parse_xlsx(content, 2026, EKBURG_PERMITS_URLS[2026])) + + assert len(rows) == 1 + row = rows[0] + assert row.permit_type == "RVE" + assert row.permit_number == "66-41-50-2022" + assert row.living_area_fact_sqm == pytest.approx(19800.5) + assert row.rve_number == "66-41-5-2026" + assert row.rve_date == date(2026, 1, 13) + + def test_parse_skips_справочник_sheet(self) -> None: + content = _make_xlsx_bytes( + { + "Справочник": [("многоквартирные жилые дома;",)], + "Лист1": [(None,)], + } + ) + client = self._make_client() + rows = list(client.parse_xlsx(content, 2026, EKBURG_PERMITS_URLS[2026])) + assert rows == [] + + def test_raw_row_stored(self) -> None: + content = _make_xlsx_bytes({"реестр разрешений на строительс": _rns_sheet_rows()}) + client = self._make_client() + rows = list(client.parse_xlsx(content, 2024, EKBURG_PERMITS_URLS[2024])) + assert len(rows) == 1 + # raw_row должен содержать dict с string keys + assert isinstance(rows[0].raw_row, dict) + assert "9" in rows[0].raw_row # колонка 9 = permit_number + + +# ── unit tests: download_xlsx ───────────────────────────────────────────────── + + +class TestDownloadXlsx: + def _client_with_mock_http(self, mock_response: MagicMock) -> EkburgPermitsClient: + """Создать EkburgPermitsClient с замоканным внутренним httpx.Client.""" + client = EkburgPermitsClient.__new__(EkburgPermitsClient) + mock_http = MagicMock() + mock_http.get.return_value = mock_response + client._client = mock_http + return client + + def test_download_returns_bytes(self) -> None: + fake_content = b"PK\x03\x04fake_xlsx_content" + mock_response = MagicMock() + mock_response.content = fake_content + mock_response.raise_for_status = MagicMock() + + client = self._client_with_mock_http(mock_response) + result = client.download_xlsx(2026) + + assert result == fake_content + + def test_download_raises_for_unknown_year(self) -> None: + client = EkburgPermitsClient.__new__(EkburgPermitsClient) + client._client = MagicMock() + with pytest.raises(ValueError, match="year=9999"): + client.download_xlsx(9999) + + def test_download_raises_on_http_error(self) -> None: + import httpx as httpx_mod + + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = httpx_mod.HTTPStatusError( + "404", request=MagicMock(), response=MagicMock() + ) + + client = self._client_with_mock_http(mock_response) + with pytest.raises(httpx_mod.HTTPStatusError): + client.download_xlsx(2026) diff --git a/data/sql/96_ekburg_construction_permits.sql b/data/sql/96_ekburg_construction_permits.sql new file mode 100644 index 00000000..15d93512 --- /dev/null +++ b/data/sql/96_ekburg_construction_permits.sql @@ -0,0 +1,91 @@ +-- 96_ekburg_construction_permits.sql +-- Issue #105 — РНС/РВЭ ЕКБ 2022-2026 (xlsx от екатеринбург.рф, Форма 3 и 4 Росстата). +-- +-- Источник: https://xn--80acgfbsl1azdqr.xn--p1ai/дляработы/гиз/градостроительство/разрешение +-- Формат: XLSX с двумя листами: +-- «реестр разрешений на строительс» — Таблица 3 (РНС) +-- «реестр разрешений на ввод» — Таблица 4 (РВЭ) +-- +-- Refresh: monthly via tasks.ekburg_permits_sync.refresh_all +-- Geocoding: raw_coord_x / raw_coord_y — местная СКФ-66 (Свердловская обл.), +-- geocoded_lat / geocoded_lon заполняются отдельной задачей (Phase 3). + +BEGIN; + +CREATE TABLE IF NOT EXISTS ekburg_construction_permits ( + id BIGSERIAL PRIMARY KEY, + permit_type TEXT NOT NULL CHECK (permit_type IN ('RNS', 'RVE')), + + -- Реквизиты разрешения + permit_number TEXT NOT NULL, + issue_date DATE, + expiry_date DATE, + + -- Застройщик + developer_inn TEXT, + developer_name TEXT, + + -- Объект + object_name TEXT, + object_type TEXT, -- из Справочника: многоквартирные жилые дома; и т.д. + construction_address TEXT, + cadastral_number TEXT, -- кадастровый номер ЗУ (колонка 6) + + -- Площади + total_area_sqm NUMERIC, -- общая площадь по проекту, м² + living_area_sqm NUMERIC, -- площадь жилых помещений по проекту, м² + living_area_fact_sqm NUMERIC, -- площадь жилых помещений фактически (только РВЭ) + + -- Координаты (местная СКФ-66 Свердловской обл. — CRS TBD Phase 3) + raw_coord_x TEXT, + raw_coord_y TEXT, + + -- РВЭ-специфичные поля + rve_number TEXT, -- номер разрешения на ввод + rve_date DATE, -- дата разрешения на ввод + + -- Геокодирование (Phase 3) + geocoded_lat NUMERIC, + geocoded_lon NUMERIC, + geom GEOMETRY(POINT, 4326), + + -- Метаданные загрузки + source_year INT NOT NULL, + source_url TEXT, + raw_row JSONB, + fetched_at TIMESTAMPTZ DEFAULT NOW(), + + UNIQUE (permit_type, permit_number) +); + +CREATE INDEX IF NOT EXISTS gist_ekburg_permits_geom + ON ekburg_construction_permits USING GIST (geom); + +CREATE INDEX IF NOT EXISTS idx_ekburg_permits_issue_date + ON ekburg_construction_permits (issue_date); + +CREATE INDEX IF NOT EXISTS idx_ekburg_permits_developer + ON ekburg_construction_permits (developer_inn); + +CREATE INDEX IF NOT EXISTS idx_ekburg_permits_year + ON ekburg_construction_permits (source_year); + +CREATE INDEX IF NOT EXISTS idx_ekburg_permits_type_year + ON ekburg_construction_permits (permit_type, source_year); + +COMMENT ON TABLE ekburg_construction_permits IS + 'РНС/РВЭ ЕКБ 2022-2026 (Форма 3/4 Росстата). ' + 'Source: екатеринбург.рф xlsx. Refresh: monthly via tasks/ekburg_permits_sync. ' + 'Координаты raw_coord_x/y в местной СКФ-66 (CRS TBD). ' + 'Геокодирование через Nominatim — Phase 3 (отдельный PR).'; + +COMMENT ON COLUMN ekburg_construction_permits.raw_coord_x IS + 'X-координата характерной точки ЗУ (местная система координат СКФ-66 / СНСК-66).'; + +COMMENT ON COLUMN ekburg_construction_permits.raw_coord_y IS + 'Y-координата характерной точки ЗУ (местная система координат СКФ-66 / СНСК-66).'; + +COMMENT ON COLUMN ekburg_construction_permits.living_area_fact_sqm IS + 'Заполняется только для РВЭ (Таблица 4, колонка 15).'; + +COMMIT; From e4505461788aa5a36282b0dc4979b61a650b0ba9 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 16 May 2026 11:55:31 +0000 Subject: [PATCH 014/342] =?UTF-8?q?feat(site-finder):=20NSPD=20frontend=20?= =?UTF-8?q?integration=20=D0=B2=20LandTab=20(#202)=20(#212)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/site-finder/LandTab.tsx | 87 +++---- .../NspdEngineeringNearbyBlock.tsx | 211 +++++++++++++++++ .../site-finder/NspdFreshnessBadge.tsx | 110 +++++++++ .../site-finder/NspdZoningBlock.tsx | 216 ++++++++++++++++++ .../site-finder/NspdZouitOverlapsBlock.tsx | 124 ++++++++++ frontend/src/hooks/useConnectionPoints.ts | 23 ++ frontend/src/types/nspd.ts | 71 ++++++ frontend/src/types/site-finder.ts | 5 + 8 files changed, 806 insertions(+), 41 deletions(-) create mode 100644 frontend/src/components/site-finder/NspdEngineeringNearbyBlock.tsx create mode 100644 frontend/src/components/site-finder/NspdFreshnessBadge.tsx create mode 100644 frontend/src/components/site-finder/NspdZoningBlock.tsx create mode 100644 frontend/src/components/site-finder/NspdZouitOverlapsBlock.tsx create mode 100644 frontend/src/hooks/useConnectionPoints.ts create mode 100644 frontend/src/types/nspd.ts diff --git a/frontend/src/components/site-finder/LandTab.tsx b/frontend/src/components/site-finder/LandTab.tsx index ddc0fed7..e72b10ad 100644 --- a/frontend/src/components/site-finder/LandTab.tsx +++ b/frontend/src/components/site-finder/LandTab.tsx @@ -7,6 +7,10 @@ import { GeologyBlock } from "./GeologyBlock"; import { GeometrySuitabilityBlock } from "./GeometrySuitabilityBlock"; import { GeotechRiskBlock } from "./GeotechRiskBlock"; import { NeighborsBlock } from "./NeighborsBlock"; +import { NspdZoningBlock } from "./NspdZoningBlock"; +import { NspdZouitOverlapsBlock } from "./NspdZouitOverlapsBlock"; +import { NspdEngineeringNearbyBlock } from "./NspdEngineeringNearbyBlock"; +import { NspdFreshnessBadge } from "./NspdFreshnessBadge"; interface Props { data: ParcelAnalysis; @@ -17,7 +21,10 @@ export function LandTab({ data }: Props) { data.geotech_risk !== undefined || data.geology !== undefined || data.geometry_suitability !== undefined || - data.neighbors_summary !== undefined; + data.neighbors_summary !== undefined || + data.nspd_zoning !== undefined || + data.nspd_zouit_overlaps !== undefined || + data.nspd_engineering_nearby !== undefined; return (
@@ -31,51 +38,49 @@ export function LandTab({ data }: Props) { )} - {/* Zoning note */} -
- - Зонирование (ПЗЗ) - -
- Данные ПЗЗ доступны через{" "} - - Публичную кадастровую карту - - . API Росреестра закрыт в 2024 — используйте deep-link для кад. номера{" "} - {data.cad_num}. -
- +
- Открыть в ПКК - + Зонирование (ПЗЗ) + +
+
+ {/* Issue #202 — ЗОУИТ пересечения */} + {data.nspd_zouit_overlaps !== undefined && ( +
+ + Зоны с особыми условиями использования (ЗОУИТ) + + +
+ )} + + {/* Issue #202 — Инженерные объекты в 200 м */} + {data.nspd_engineering_nearby !== undefined && ( +
+ + Инженерные объекты вблизи участка + + +
+ )} + {/* Geotech risk */} {data.geotech_risk !== undefined && (
diff --git a/frontend/src/components/site-finder/NspdEngineeringNearbyBlock.tsx b/frontend/src/components/site-finder/NspdEngineeringNearbyBlock.tsx new file mode 100644 index 00000000..f7dc76cb --- /dev/null +++ b/frontend/src/components/site-finder/NspdEngineeringNearbyBlock.tsx @@ -0,0 +1,211 @@ +"use client"; + +import type { NspdEngineeringNearby } from "@/types/nspd"; +import { useConnectionPoints } from "@/hooks/useConnectionPoints"; + +interface Props { + nearby: NspdEngineeringNearby[]; + cadNum: string | null | undefined; +} + +interface MergedRow { + label: string; + type: string | null; + distanceM: number; + source: "analyze" | "connection-points"; +} + +export function NspdEngineeringNearbyBlock({ nearby, cadNum }: Props) { + const { data: cpData, isPending: cpLoading } = useConnectionPoints(cadNum); + + // Merge and sort by distance + const rows: MergedRow[] = []; + + for (const item of nearby) { + if (item.distance_m !== null) { + rows.push({ + label: item.name ?? item.type ?? "Объект", + type: item.type, + distanceM: item.distance_m, + source: "analyze", + }); + } + } + + if (cpData) { + for (const s of cpData.engineering_structures) { + rows.push({ + label: s.name ?? s.type ?? "Объект", + type: s.type, + distanceM: s.distance_to_boundary_m, + source: "connection-points", + }); + } + } + + // Dedup by label+distance (simple check) + const seen = new Set(); + const deduped = rows.filter((r) => { + const key = `${r.label}|${Math.round(r.distanceM)}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + + deduped.sort((a, b) => a.distanceM - b.distanceM); + + if (deduped.length === 0 && !cpLoading) { + return ( +
+ Инженерных объектов в 200 м не найдено +
+ ); + } + + return ( +
+ {deduped.length > 0 && ( + + + + + + + + + + {deduped.map((row, idx) => ( + + + + + + ))} + +
+ Объект + + Тип + + Расстояние +
+ {row.label} + + {row.type ?? "—"} + + {Math.round(row.distanceM)} м +
+ )} + + {cpLoading && ( +
+ Загружаем данные точек подключения… +
+ )} + + {cpData?.summary && ( +
+ {cpData.summary.in_protection_zone && ( + + В охранной зоне + + )} + {cpData.summary.nearest_structure_distance_m !== null && ( + + Ближайший:{" "} + {Math.round(cpData.summary.nearest_structure_distance_m)} м + + )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/site-finder/NspdFreshnessBadge.tsx b/frontend/src/components/site-finder/NspdFreshnessBadge.tsx new file mode 100644 index 00000000..c1033b74 --- /dev/null +++ b/frontend/src/components/site-finder/NspdFreshnessBadge.tsx @@ -0,0 +1,110 @@ +"use client"; + +import type { NspdDumpMeta } from "@/types/nspd"; + +interface Props { + dump: NspdDumpMeta | null | undefined; +} + +export function NspdFreshnessBadge({ dump }: Props) { + if (!dump) { + return ( + + НСПД: нет данных + + ); + } + + if (dump.harvest_triggered && !dump.available) { + return ( + + НСПД: загрузка… + + ); + } + + if (!dump.available) { + return ( + + НСПД: нет дампа + + ); + } + + const dateStr = dump.fetched_at_utc + ? new Date(dump.fetched_at_utc).toLocaleDateString("ru-RU") + : null; + + if (dump.stale) { + return ( + + НСПД: устарело{dateStr ? ` (${dateStr})` : ""} + + ); + } + + return ( + + НСПД: актуально{dateStr ? ` (${dateStr})` : ""} + + ); +} diff --git a/frontend/src/components/site-finder/NspdZoningBlock.tsx b/frontend/src/components/site-finder/NspdZoningBlock.tsx new file mode 100644 index 00000000..f9c90421 --- /dev/null +++ b/frontend/src/components/site-finder/NspdZoningBlock.tsx @@ -0,0 +1,216 @@ +"use client"; + +import { useState } from "react"; + +import type { NspdDumpMeta, NspdZoning } from "@/types/nspd"; + +interface Props { + data: NspdZoning | null | undefined; + dump: NspdDumpMeta | null | undefined; + cadNum: string; +} + +export function NspdZoningBlock({ data, dump, cadNum }: Props) { + const [expanded, setExpanded] = useState(false); + + const isHarvesting = dump?.harvest_triggered && !dump?.available; + + // Loading skeleton when harvest is in progress + if (isHarvesting && !data) { + return ( +
+
+ + + Загружаем данные НСПД, 15–30 с… + +
+
+
+
+ ); + } + + // Data not available, no harvest triggered + if (!data) { + return ( +
+
+ Данные ПЗЗ из НСПД недоступны. Используйте{" "} + + Публичную кадастровую карту + {" "} + для кад. номера {cadNum} + . +
+ + Открыть в ПКК + +
+ ); + } + + return ( +
+
+ {data.zone_code && ( + + {data.zone_code} + + )} + {data.zone_name && ( + + {data.zone_name} + + )} + {!data.zone_code && !data.zone_name && ( + + Зона не определена + + )} +
+ + {data.raw_props && Object.keys(data.raw_props).length > 0 && ( +
+ + {expanded && ( +
+ {Object.entries(data.raw_props).map(([k, v]) => ( +
+ {k}: + {String(v ?? "—")} +
+ ))} +
+ )} +
+ )} + + {dump?.fetched_at_utc && ( +
+ НСПД: данные от{" "} + {new Date(dump.fetched_at_utc).toLocaleDateString("ru-RU")} + {dump.stale && ( + + (устаревшие) + + )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/site-finder/NspdZouitOverlapsBlock.tsx b/frontend/src/components/site-finder/NspdZouitOverlapsBlock.tsx new file mode 100644 index 00000000..4a08f6ea --- /dev/null +++ b/frontend/src/components/site-finder/NspdZouitOverlapsBlock.tsx @@ -0,0 +1,124 @@ +"use client"; + +import type { NspdZouitOverlap } from "@/types/nspd"; + +interface Props { + overlaps: NspdZouitOverlap[]; +} + +// Group key → human-readable label +const GROUP_LABELS: Record = { + engineering: "Инженерные коммуникации", + okn: "Объекты культурного наследия", + natural: "Природные территории", + protected: "Охраняемые зоны", + other: "Прочие ЗОУИТ", +}; + +// Determine a color-coded severity badge from group_key +function getSeverityStyle(groupKey: string): { + bg: string; + color: string; + label: string; +} { + switch (groupKey) { + case "engineering": + return { bg: "#fef3c7", color: "#92400e", label: "warn" }; + case "okn": + return { bg: "#fee2e2", color: "#991b1b", label: "block" }; + case "protected": + return { bg: "#fee2e2", color: "#991b1b", label: "block" }; + case "natural": + return { bg: "#dbeafe", color: "#1e40af", label: "low" }; + default: + return { bg: "#f3f4f6", color: "#374151", label: "info" }; + } +} + +export function NspdZouitOverlapsBlock({ overlaps }: Props) { + if (overlaps.length === 0) { + return ( +
+ + Нет пересечений + + + Охранных зон ЗОУИТ не обнаружено + +
+ ); + } + + return ( +
+ {overlaps.map((overlap, idx) => { + const style = getSeverityStyle(overlap.group_key); + const label = GROUP_LABELS[overlap.group_key] ?? overlap.group_key; + const detail = + overlap.name || + (typeof overlap.subcategory === "string" + ? overlap.subcategory + : null); + + return ( +
+ + {style.label.toUpperCase()} + +
+
+ {label} +
+ {detail && ( +
+ {detail} +
+ )} +
+
+ ); + })} +
+ ); +} diff --git a/frontend/src/hooks/useConnectionPoints.ts b/frontend/src/hooks/useConnectionPoints.ts new file mode 100644 index 00000000..9e02174b --- /dev/null +++ b/frontend/src/hooks/useConnectionPoints.ts @@ -0,0 +1,23 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +import { apiFetch } from "@/lib/api"; +import type { ConnectionPointsResponse } from "@/types/nspd"; + +export type { ConnectionPointsResponse }; + +export function useConnectionPoints( + cadNum: string | null | undefined, + enabled = true, +) { + return useQuery({ + queryKey: ["connection-points", cadNum], + queryFn: () => + apiFetch( + `/api/v1/parcels/${encodeURIComponent(cadNum!)}/connection-points`, + ), + enabled: !!cadNum && enabled, + staleTime: 5 * 60 * 1000, + }); +} diff --git a/frontend/src/types/nspd.ts b/frontend/src/types/nspd.ts new file mode 100644 index 00000000..daf84416 --- /dev/null +++ b/frontend/src/types/nspd.ts @@ -0,0 +1,71 @@ +// NSPD (НСПД) types — Issue #202 +// Shapes derived from backend/app/services/site_finder/quarter_dump_lookup.py +// and backend/app/schemas/parcel.py + +export interface NspdZoning { + zone_code: string | null; + zone_name: string | null; + source?: string; + raw_props?: Record; +} + +export interface NspdZouitOverlap { + group_key: string; // e.g. "engineering", "okn", "natural", "protected", "other" + layer: string; // e.g. "zouit_engineering" + subcategory: string | number | null; + name: string | null; + raw_props?: Record; +} + +export interface NspdEngineeringNearby { + name: string | null; + type: string | null; + distance_m: number | null; + raw_props?: Record; +} + +export interface NspdDumpMeta { + available: boolean; + fetched_at_utc: string | null; // ISO datetime + stale: boolean; + harvest_triggered: boolean; + total_features: number | null; +} + +// Connection-points endpoint shapes (/api/v1/parcels/{cad}/connection-points) + +export interface EngineeringStructure { + name: string | null; + type: string | null; + cad_num: string | null; + distance_to_boundary_m: number; + geometry_geojson: Record; + readable_address: string | null; + raw_props: Record; + source: string; +} + +export interface ZouitEngineeringOverlap { + reg_numb_border: string | null; + type_zone: string | null; + subcategory: number | null; + intersects_parcel: boolean; + geometry_geojson: Record; + raw_props: Record; + source: string; +} + +export interface ConnectionPointsSummary { + nearest_structure_distance_m: number | null; + in_protection_zone: boolean; + protection_zones_intersecting: number; + total_structures_in_radius: number; +} + +export interface ConnectionPointsResponse { + engineering_structures: EngineeringStructure[]; + zouit_engineering_overlaps: ZouitEngineeringOverlap[]; + summary: ConnectionPointsSummary; + dump_available: boolean; + dump_fetched_at: string | null; +} diff --git a/frontend/src/types/site-finder.ts b/frontend/src/types/site-finder.ts index 7217fd6b..2af9ee13 100644 --- a/frontend/src/types/site-finder.ts +++ b/frontend/src/types/site-finder.ts @@ -379,6 +379,11 @@ export interface ParcelAnalysis { gate_verdict?: GateVerdict; // D2 (#34) — velocity-score: темп продаж конкурентов velocity?: Velocity | null; + // Issue #202 — NSPD frontend integration + nspd_zoning?: import("./nspd").NspdZoning | null; + nspd_zouit_overlaps?: import("./nspd").NspdZouitOverlap[]; + nspd_engineering_nearby?: import("./nspd").NspdEngineeringNearby[]; + nspd_dump?: import("./nspd").NspdDumpMeta | null; } export type PoiCategory = From 46203ab365cb5f27d3abe532cbaca41db4ec13b5 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 16 May 2026 12:23:52 +0000 Subject: [PATCH 015/342] feat(permits): Celery beat monthly + analyze recent_permits (#105 Phase 4+5) (#213) --- backend/app/api/v1/parcels.py | 65 ++++ backend/app/schemas/parcel.py | 26 ++ backend/app/workers/beat_schedule.py | 7 + .../api/v1/test_analyze_inline_weights.py | 8 +- .../tests/api/v1/test_analyze_market_price.py | 7 +- .../api/v1/test_analyze_recent_permits.py | 281 ++++++++++++++++++ 6 files changed, 388 insertions(+), 6 deletions(-) create mode 100644 backend/tests/api/v1/test_analyze_recent_permits.py diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 20edea76..d16458c5 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -1849,6 +1849,68 @@ def analyze_parcel( logger.warning("market_price query failed for %s: %s", cad_num, e) market_price = {"deals_count": 0, "source": "no_data"} + # 10d-pre2) #105 Phase 5: РНС/РВЭ в квартале (cad_num quarter prefix match). + # Spatial filter через geom недоступен до Phase 3 (geocoding → geom). + # Используем quarter prefix (первые 3 сегмента кад. номера) как прокси. + # После Phase 3: заменить на ST_DWithin(..., 500) по geom. + recent_permits: list[dict[str, Any]] = [] + permits_summary: dict[str, Any] = { + "rns_count": 0, + "rve_count": 0, + "rns_total_area_sqm": 0.0, + "by_developer": [], + } + try: + with db.begin_nested(): + permits_rows = ( + db.execute( + text(""" + SELECT + permit_type, permit_number, issue_date, + developer_name, developer_inn, object_name, object_type, + construction_address, total_area_sqm + FROM ekburg_construction_permits + WHERE LEFT(cadastral_number, LENGTH(CAST(:q AS text))) = CAST(:q AS text) + AND issue_date > NOW() - INTERVAL '24 months' + ORDER BY issue_date DESC + LIMIT 50 + """), + {"q": _quarter_cad}, + ) + .mappings() + .all() + ) + recent_permits = [ + { + "permit_type": r["permit_type"], + "permit_number": r["permit_number"], + "issue_date": r["issue_date"].isoformat() if r["issue_date"] else None, + "developer_name": r["developer_name"], + "developer_inn": r["developer_inn"], + "object_name": r["object_name"], + "object_type": r["object_type"], + "construction_address": r["construction_address"], + "total_area_sqm": float(r["total_area_sqm"]) if r["total_area_sqm"] else None, + } + for r in permits_rows + ] + rns_list = [p for p in recent_permits if p["permit_type"] == "RNS"] + by_dev: dict[str, int] = {} + for p in recent_permits: + if p["developer_name"]: + by_dev[p["developer_name"]] = by_dev.get(p["developer_name"], 0) + 1 + permits_summary = { + "rns_count": len(rns_list), + "rve_count": len([p for p in recent_permits if p["permit_type"] == "RVE"]), + "rns_total_area_sqm": sum(p["total_area_sqm"] or 0.0 for p in rns_list), + "by_developer": [ + {"name": name, "permits_count": cnt} + for name, cnt in sorted(by_dev.items(), key=lambda x: -x[1])[:3] + ], + } + except Exception as e: + logger.warning("ekburg permits query failed for %s: %s", cad_num, e) + # 10d) Geology stub — реальные данные требуют ВСЕГЕИ-200/1000 шейпы в PostGIS karpinsky_url = ( f"https://www.karpinskyinstitute.ru/ru/gisatlas/web-gisatlas/" @@ -1986,6 +2048,9 @@ def analyze_parcel( "market_trend": market_trend, # #33 D2: квартальная ценовая статистика из mv_quarter_price_per_m2 "market_price": market_price, + # #105 Phase 5: РНС/РВЭ в квартале (quarter prefix match; после Phase 3 → spatial 500m) + "recent_permits_in_quarter": recent_permits, + "permits_summary": permits_summary, "zoning": zoning, "success_recommendation": success_recommendation, "isochrones_available": bool(settings.openrouteservice_api_key), diff --git a/backend/app/schemas/parcel.py b/backend/app/schemas/parcel.py index 1f67c09a..7da0ce4b 100644 --- a/backend/app/schemas/parcel.py +++ b/backend/app/schemas/parcel.py @@ -3,6 +3,32 @@ from typing import Any, Literal from pydantic import BaseModel, Field +# ── #105 Phase 5: Recent permits schemas ────────────────────────────────────── + + +class RecentPermit(BaseModel): + """Одно строительное разрешение (РНС или РВЭ) из ekburg_construction_permits.""" + + permit_type: str + permit_number: str + issue_date: str | None + developer_name: str | None + developer_inn: str | None + object_name: str | None + object_type: str | None + construction_address: str | None + total_area_sqm: float | None + + +class PermitsSummary(BaseModel): + """Агрегированная сводка по разрешениям в квартале.""" + + rns_count: int + rve_count: int + rns_total_area_sqm: float + by_developer: list[dict[str, Any]] + + # ── Connection points schemas (issue #115) ──────────────────────────────────── diff --git a/backend/app/workers/beat_schedule.py b/backend/app/workers/beat_schedule.py index e7ce5146..69552bfd 100644 --- a/backend/app/workers/beat_schedule.py +++ b/backend/app/workers/beat_schedule.py @@ -219,6 +219,13 @@ def build_beat_schedule() -> dict: "options": {"queue": "celery"}, } + # #105 Phase 4: ЕКБ РНС/РВЭ — ежемесячно 1-го числа в 05:00 МСК (02:00 UTC) + schedule["ekburg-permits-monthly"] = { + "task": "tasks.ekburg_permits_sync.refresh_all", + "schedule": _parse_cron("0 2 1 * *"), + "options": {"queue": "celery"}, + } + # NSPD quarter dump refresh — DISABLED 2026-05-14 per Bug_NSPD_WMS_NotBulk # post-mortem (vault: fixes/Bug_NSPD_WMS_NotBulk_2026_May14.md). # diff --git a/backend/tests/api/v1/test_analyze_inline_weights.py b/backend/tests/api/v1/test_analyze_inline_weights.py index 80a1a9d6..f49eeed6 100644 --- a/backend/tests/api/v1/test_analyze_inline_weights.py +++ b/backend/tests/api/v1/test_analyze_inline_weights.py @@ -61,9 +61,11 @@ def _make_db_for_analyze( 10. Market trend → .mappings().first() 11. Zoning (begin_nested) → .mappings().first() 12. Success recommendation (begin_nested) → .mappings().all() - 13. _geotech_risk (industrial count) → .scalar() - 14. _neighbors_summary (neighbor_rows) → .mappings().all() - 15. _neighbors_summary (overlap_row) → .mappings().first() + 13. Market price (begin_nested) → .mappings().first() + 14. Recent permits (begin_nested) → .mappings().all() ← NEW #105 Phase 5 + 15. _geotech_risk (industrial count) → .scalar() + 16. _neighbors_summary (neighbor_rows) → .mappings().all() + 17. _neighbors_summary (overlap_row) → .mappings().first() begin_nested() — возвращаем context manager чтобы поддержать `with` statement. """ diff --git a/backend/tests/api/v1/test_analyze_market_price.py b/backend/tests/api/v1/test_analyze_market_price.py index 4b44242f..4052f882 100644 --- a/backend/tests/api/v1/test_analyze_market_price.py +++ b/backend/tests/api/v1/test_analyze_market_price.py @@ -23,9 +23,10 @@ dependency_overrides, тяжёлые сервисы патчим через unit 11. Zoning (begin_nested) → .mappings().first() 12. Success recommendation (begin_nested) → .mappings().all() 13. Market price (begin_nested) → .mappings().first() ← NEW #33 - 14. _geotech_risk (industrial count) → .scalar() - 15. _neighbors_summary (neighbor_rows) → .mappings().all() - 16. _neighbors_summary (overlap_row) → .mappings().first() + 14. Recent permits (begin_nested) → .mappings().all() ← NEW #105 Phase 5 + 15. _geotech_risk (industrial count) → .scalar() + 16. _neighbors_summary (neighbor_rows) → .mappings().all() + 17. _neighbors_summary (overlap_row) → .mappings().first() """ from __future__ import annotations diff --git a/backend/tests/api/v1/test_analyze_recent_permits.py b/backend/tests/api/v1/test_analyze_recent_permits.py new file mode 100644 index 00000000..1549da02 --- /dev/null +++ b/backend/tests/api/v1/test_analyze_recent_permits.py @@ -0,0 +1,281 @@ +"""Тесты для recent_permits_in_quarter в POST /api/v1/parcels/{cad_num}/analyze (#105 Phase 5). + +Покрывает: +1. analyze с quarter где есть РНС/РВЭ → recent_permits non-empty + summary calculated +2. analyze с quarter без permits → recent_permits=[], summary={rns_count=0, ...} +3. analyze 404 на invalid cad (no regression) + +Порядок db.execute calls в analyze_parcel (после #105 Phase 5): + 0. UNION ALL geom + source → .mappings().first() + 1. WKT query → .mappings().first() + 2. District → .mappings().first() + 3. POI rows → .mappings().all() + 4. Competitor rows → .mappings().all() + 5. Pipeline rows → .mappings().all() + 6. Centroid lat/lon → .mappings().first() + 7. Noise rows → .mappings().all() + 8. Hydrology → .mappings().all() + 9. Utilities → .mappings().all() + 10. Market trend → .mappings().first() + 11. Zoning (begin_nested) → .mappings().first() + 12. Success recommendation (begin_nested) → .mappings().all() + 13. Market price (begin_nested) → .mappings().first() + 14. Recent permits (begin_nested) → .mappings().all() ← NEW #105 + 15. _geotech_risk (industrial count) → .scalar() + 16. _neighbors_summary (neighbor_rows) → .mappings().all() + 17. _neighbors_summary (overlap_row) → .mappings().first() +""" + +from __future__ import annotations + +import datetime as dt +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +from app.main import app + +# ── Константы ───────────────────────────────────────────────────────────────── + +_CAD = "66:41:0204016:10" # cad_num с 4 частями — quarter prefix = "66:41:0204016" +_WKT = "POLYGON((60.6 56.838, 60.61 56.838, 60.61 56.845, 60.6 56.845, 60.6 56.838))" +_GEOJSON = '{"type":"Polygon","coordinates":[[[60.6,56.838],[60.61,56.838]]]}' + + +# ── Mock factories ───────────────────────────────────────────────────────────── + + +def _make_mapping(data: dict[str, Any]) -> MagicMock: + """Создать mock-строку (mapping) с __getitem__ + .get() для dict-like доступа.""" + m = MagicMock() + m.__getitem__ = lambda self, k: data[k] + m.get = lambda k, default=None: data.get(k, default) + return m + + +def _make_db_for_analyze( + geom_found: bool = True, + permits_rows: list[dict[str, Any]] | None = None, +) -> MagicMock: + """Сконструировать mock DB Session для analyze_parcel. + + permits_rows=None → пустой список (нет разрешений в квартале). + permits_rows=[...] → список разрешений для агрегации. + """ + db = MagicMock() + + geom_row = ( + _make_mapping({"geom_geojson": _GEOJSON, "geom_wkb": None, "source": "cad_quarter"}) + if geom_found + else None + ) + wkt_row = _make_mapping({"wkt": _WKT}) if geom_found else None + district_row = _make_mapping( + { + "district_name": "Октябрьский", + "median_price_per_m2": 120000, + "dist_to_center": 1500.0, + } + ) + centroid_row = _make_mapping({"lat": 56.84, "lon": 60.605}) + + raw_permits = [_make_mapping(p) for p in (permits_rows or [])] + + call_idx = [0] + responses: list[Any] = [ + ("first", geom_row), # 0: geom UNION ALL + ("first", wkt_row), # 1: WKT + ("first", district_row), # 2: district + ("all", []), # 3: POI rows + ("all", []), # 4: competitor rows + ("all", []), # 5: pipeline rows + ("first", centroid_row), # 6: centroid + ("all", []), # 7: noise rows + ("all", []), # 8: hydrology rows + ("all", []), # 9: utilities rows + ("first", None), # 10: market trend + ("first", None), # 11: zoning (begin_nested) + ("all", []), # 12: success recommendation (begin_nested) + ("first", None), # 13: market price (begin_nested) + ("all", raw_permits), # 14: recent permits (begin_nested) ← #105 + ("scalar", 0), # 15: geotech_risk + ("all", []), # 16: neighbors + ("first", None), # 17: overlap + ] + + def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock: + idx = call_idx[0] + call_idx[0] += 1 + if idx >= len(responses): + r = MagicMock() + r.mappings.return_value.first.return_value = None + r.mappings.return_value.all.return_value = [] + r.scalar.return_value = 0 + return r + kind, data = responses[idx] + r = MagicMock() + r.mappings.return_value.first.return_value = data + r.mappings.return_value.all.return_value = data if isinstance(data, list) else [] + r.scalar.return_value = data if kind == "scalar" else 0 + return r + + db.execute.side_effect = _execute_side_effect + + ctx = MagicMock() + ctx.__enter__ = MagicMock(return_value=ctx) + ctx.__exit__ = MagicMock(return_value=False) + db.begin_nested.return_value = ctx + + return db + + +def _override_db(db: MagicMock): + def _get_db_override(): + yield db + + return _get_db_override + + +_PATCHES = [ + patch("app.api.v1.parcels._fetch_air_quality_sync", return_value=None), + patch("app.api.v1.parcels._fetch_weather_sync", return_value=None), + patch("app.api.v1.parcels._fetch_seasonal_weather_sync", return_value=None), + patch( + "app.api.v1.parcels.get_quarter_dump_data", + return_value={ + "nspd_zoning": None, + "nspd_zouit_overlaps": [], + "nspd_engineering_nearby": [], + "nspd_dump": {"available": False, "stale": False, "harvest_triggered": False}, + }, + ), + patch("app.api.v1.parcels.compute_velocity", return_value=None), + patch("app.api.v1.parcels.compute_gate_verdict", return_value={"verdict": "unknown"}), +] + + +def _start_patches() -> None: + for p in _PATCHES: + p.start() + + +def _stop_patches() -> None: + for p in _PATCHES: + p.stop() + + +# ── Тесты ───────────────────────────────────────────────────────────────────── + + +def test_recent_permits_found_in_quarter() -> None: + """analyze с квартала где есть РНС/РВЭ → recent_permits non-empty + summary calculated.""" + from app.core.db import get_db + + rns_permit: dict[str, Any] = { + "permit_type": "RNS", + "permit_number": "66-RNS-2024-001", + "issue_date": dt.date(2024, 6, 15), + "developer_name": "Атомстрой", + "developer_inn": "6671234567", + "object_name": "МКД ЖК Тест", + "object_type": "МКД", + "construction_address": "г. Екатеринбург, ул. Ленина, 1", + "total_area_sqm": 45000.0, + } + rve_permit: dict[str, Any] = { + "permit_type": "RVE", + "permit_number": "66-RVE-2025-002", + "issue_date": dt.date(2025, 3, 10), + "developer_name": "Атомстрой", + "developer_inn": "6671234567", + "object_name": "МКД ЖК Тест (1 оч.)", + "object_type": "МКД", + "construction_address": "г. Екатеринбург, ул. Ленина, 1", + "total_area_sqm": None, + } + + db = _make_db_for_analyze(permits_rows=[rns_permit, rve_permit]) + app.dependency_overrides[get_db] = _override_db(db) + _start_patches() + try: + client = TestClient(app) + resp = client.post(f"/api/v1/parcels/{_CAD}/analyze") + assert resp.status_code == 200, resp.text + body = resp.json() + + assert "recent_permits_in_quarter" in body + assert "permits_summary" in body + + permits = body["recent_permits_in_quarter"] + assert len(permits) == 2 + + rns = next(p for p in permits if p["permit_type"] == "RNS") + assert rns["permit_number"] == "66-RNS-2024-001" + assert rns["developer_name"] == "Атомстрой" + assert rns["issue_date"] == "2024-06-15" + assert rns["total_area_sqm"] == pytest.approx(45000.0) + + rve = next(p for p in permits if p["permit_type"] == "RVE") + assert rve["issue_date"] == "2025-03-10" + assert "units_count" not in rve + + summary = body["permits_summary"] + assert summary["rns_count"] == 1 + assert summary["rve_count"] == 1 + assert summary["rns_total_area_sqm"] == pytest.approx(45000.0) + assert "rve_total_units" not in summary + assert len(summary["by_developer"]) == 1 + assert summary["by_developer"][0]["name"] == "Атомстрой" + assert summary["by_developer"][0]["permits_count"] == 2 + finally: + app.dependency_overrides.clear() + _stop_patches() + + +def test_recent_permits_empty_quarter() -> None: + """analyze с quarter без permits → recent_permits=[], summary с нулями.""" + from app.core.db import get_db + + db = _make_db_for_analyze(permits_rows=[]) + app.dependency_overrides[get_db] = _override_db(db) + _start_patches() + try: + client = TestClient(app) + resp = client.post(f"/api/v1/parcels/{_CAD}/analyze") + assert resp.status_code == 200, resp.text + body = resp.json() + + assert "recent_permits_in_quarter" in body + assert body["recent_permits_in_quarter"] == [] + + assert "permits_summary" in body + summary = body["permits_summary"] + assert summary["rns_count"] == 0 + assert summary["rve_count"] == 0 + assert summary["rns_total_area_sqm"] == pytest.approx(0.0) + assert "rve_total_units" not in summary + assert summary["by_developer"] == [] + finally: + app.dependency_overrides.clear() + _stop_patches() + + +def test_recent_permits_invalid_cad_no_regression() -> None: + """analyze с несуществующим cad → 202 или 404 (no regression от Phase 5).""" + from app.core.db import get_db + + db = _make_db_for_analyze(geom_found=False) + app.dependency_overrides[get_db] = _override_db(db) + _start_patches() + try: + client = TestClient(app) + resp = client.post("/api/v1/parcels/00:00:0000000:0/analyze") + assert resp.status_code in ( + 202, + 404, + ), f"Ожидали 202 или 404 для неизвестного cad, получили {resp.status_code}: {resp.text}" + finally: + app.dependency_overrides.clear() + _stop_patches() From dbf31cc9118998829da058d51f14f6f96df48a14 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 16 May 2026 12:47:09 +0000 Subject: [PATCH 016/342] feat(etl): objective_complex_mapping backfill via pg_trgm (#203) (#214) --- backend/app/api/v1/admin_etl.py | 70 ++++++ backend/app/main.py | 6 + backend/app/services/etl/__init__.py | 0 .../app/services/etl/objective_backfill.py | 218 ++++++++++++++++++ .../tests/services/test_objective_backfill.py | 176 ++++++++++++++ data/sql/97_objective_backfill_indexes.sql | 31 +++ 6 files changed, 501 insertions(+) create mode 100644 backend/app/api/v1/admin_etl.py create mode 100644 backend/app/services/etl/__init__.py create mode 100644 backend/app/services/etl/objective_backfill.py create mode 100644 backend/tests/services/test_objective_backfill.py create mode 100644 data/sql/97_objective_backfill_indexes.sql diff --git a/backend/app/api/v1/admin_etl.py b/backend/app/api/v1/admin_etl.py new file mode 100644 index 00000000..96b2d51a --- /dev/null +++ b/backend/app/api/v1/admin_etl.py @@ -0,0 +1,70 @@ +"""Admin endpoints для ETL operations (#203). + +POST /api/v1/admin/etl/objective-backfill + Запустить fuzzy-match backfill objective_complex_mapping. + Опциональный REFRESH mv_layout_velocity после успешного backfill. + +Header: X-Admin-Token: +""" + +from __future__ import annotations + +import logging +from typing import Annotated + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.core.db import get_db +from app.core.deps import AdminTokenAuth +from app.services.etl.objective_backfill import ( + REVIEW_THRESHOLD, + auto_apply_matches, + find_match_candidates, + trigger_mv_refresh, +) + +logger = logging.getLogger(__name__) +router = APIRouter() + + +@router.post("/objective-backfill") +def run_objective_backfill( + db: Annotated[Session, Depends(get_db)], + _: AdminTokenAuth, + dry_run: Annotated[bool, Query(description="Preview без insertions")] = False, + refresh_mv: Annotated[ + bool, Query(description="REFRESH mv_layout_velocity после backfill") + ] = True, +) -> dict[str, int]: + """Запустить backfill objective_complex_mapping + опционально REFRESH MV. + + Ищет DOM.РФ комплексы (is_ekb=true) без mapping и применяет fuzzy match + к project_name из objective_corpus_room_month через pg_trgm similarity. + + - score >= 0.85 (AUTO_ACCEPT_THRESHOLD): auto-insert с match_method='fuzzy_trgm' + - score >= 0.6 (REVIEW_THRESHOLD) и < 0.85: только в счётчик review_queue + (Phase 2 — UI для ручного review) + + Returns dict: + auto_accepted: сколько строк вставлено + review_queue: сколько кандидатов ниже порога auto-accept + skipped: ON CONFLICT + ошибки + mv_rows_after_refresh: строк в MV после REFRESH (0 если refresh_mv=False) + """ + candidates = find_match_candidates(db, only_unmapped=True) + logger.info( + "Backfill candidates found: %d (score >= %.2f)", + len(candidates), + REVIEW_THRESHOLD, + ) + + result = auto_apply_matches(db, candidates, dry_run=dry_run) + + mv_rows = 0 + if refresh_mv and not dry_run and result["auto_accepted"] > 0: + mv_rows = trigger_mv_refresh(db) + logger.info("mv_layout_velocity refreshed after backfill: %d rows", mv_rows) + + result["mv_rows_after_refresh"] = mv_rows + return result diff --git a/backend/app/main.py b/backend/app/main.py index 93db3a29..4197a6b4 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,6 +7,7 @@ from fastapi.middleware.cors import CORSMiddleware from app.api.v1 import ( admin_cadastre, + admin_etl, admin_jobs, admin_leads, admin_scrape, @@ -58,6 +59,11 @@ app.include_router( prefix="/api/v1/admin/cadastre", tags=["admin", "cadastre"], ) +app.include_router( + admin_etl.router, + prefix="/api/v1/admin/etl", + tags=["admin", "etl"], +) @app.get("/health") diff --git a/backend/app/services/etl/__init__.py b/backend/app/services/etl/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/app/services/etl/objective_backfill.py b/backend/app/services/etl/objective_backfill.py new file mode 100644 index 00000000..dc566af9 --- /dev/null +++ b/backend/app/services/etl/objective_backfill.py @@ -0,0 +1,218 @@ +"""Backfill objective_complex_mapping (#203). + +Auto-match DOM.РФ комплексов к Objective dataset через fuzzy name matching. +Goal: coverage 3% → 40%+ для mv_layout_velocity. + +Schema facts (confirmed via pg MCP): +- domrf_kn_objects: фильтр is_ekb = true (~1285 ЕКБ объектов). + district_name ILIKE '%екатеринбург%' возвращает 0 строк — не использовать. +- objective_corpus_room_month: group_name = 'Екатеринбург' (единственное значение), + 263 distinct project_name. +- objective_complex_mapping: UNIQUE (objective_complex_name, objective_group), + колонки match_method + match_score + is_reviewed поддерживают audit trail. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass + +from sqlalchemy import text +from sqlalchemy.orm import Session + +logger = logging.getLogger(__name__) + +# Порог для auto-insert (высокая уверенность) +AUTO_ACCEPT_THRESHOLD = 0.85 + +# Порог для review queue (средняя уверенность — Phase 2) +REVIEW_THRESHOLD = 0.6 + + +@dataclass +class MatchCandidate: + """Один candidate match DOM.РФ ↔ Objective.""" + + domrf_obj_id: int + domrf_comm_name: str + domrf_dev_name: str | None + objective_project_name: str + similarity_score: float # 0.0..1.0 + + +def find_match_candidates( + db: Session, + *, + only_unmapped: bool = True, + limit: int | None = None, +) -> list[MatchCandidate]: + """Поиск candidates через pg_trgm similarity. + + Использует CROSS JOIN LATERAL + similarity() для fuzzy match + comm_name (DOM.РФ) ↔ project_name (Objective). + + Args: + db: SQLAlchemy sync Session. + only_unmapped: Если True — пропускает уже-mapped obj_id. + limit: Максимальное число строк результата (для тестирования). + + Returns: + Список MatchCandidate, отсортированных по убыванию similarity_score. + """ + # Формируем SQL. LIMIT добавляем через int() — SQL injection safe (только число). + limit_clause = f"LIMIT {int(limit)}" if limit is not None else "" + + sql = text( + f""" + WITH domrf_unmapped AS ( + SELECT o.obj_id, o.comm_name, o.dev_name + FROM domrf_kn_objects o + WHERE o.is_ekb = true + AND o.comm_name IS NOT NULL + AND ( + CAST(:only_unmapped AS boolean) = FALSE + OR NOT EXISTS ( + SELECT 1 FROM objective_complex_mapping cm + WHERE cm.domrf_obj_id = o.obj_id + ) + ) + ), + objective_distinct AS ( + SELECT DISTINCT project_name + FROM objective_corpus_room_month + ) + SELECT + d.obj_id, + d.comm_name, + d.dev_name, + obj.project_name, + similarity(d.comm_name, obj.project_name) AS sim_score + FROM domrf_unmapped d + CROSS JOIN LATERAL ( + SELECT project_name + FROM objective_distinct + WHERE similarity(d.comm_name, project_name) > 0.4 + ORDER BY similarity(d.comm_name, project_name) DESC + LIMIT 1 + ) obj + WHERE similarity(d.comm_name, obj.project_name) >= CAST(:min_threshold AS float) + ORDER BY sim_score DESC + {limit_clause} + """ + ) + + rows = db.execute( + sql, + {"only_unmapped": only_unmapped, "min_threshold": REVIEW_THRESHOLD}, + ).all() + + return [ + MatchCandidate( + domrf_obj_id=int(r[0]), + domrf_comm_name=str(r[1]), + domrf_dev_name=str(r[2]) if r[2] is not None else None, + objective_project_name=str(r[3]), + similarity_score=float(r[4]), + ) + for r in rows + ] + + +def auto_apply_matches( + db: Session, + candidates: list[MatchCandidate], + *, + threshold: float = AUTO_ACCEPT_THRESHOLD, + dry_run: bool = False, +) -> dict[str, int]: + """Apply candidates с score >= threshold в objective_complex_mapping. + + Кандидаты ниже threshold, но >= REVIEW_THRESHOLD попадают в review_queue + (Phase 2 — UI для ручного review, в этом PR только считаются). + + ON CONFLICT DO NOTHING — если пара (objective_complex_name, objective_group) + уже существует, строка пропускается без ошибки. + + Args: + db: SQLAlchemy sync Session. + candidates: Список из find_match_candidates(). + threshold: Минимальный score для auto-insert (default 0.85). + dry_run: Если True — только логирует, не пишет в БД. + + Returns: + dict с ключами auto_accepted, review_queue, skipped. + """ + auto = [c for c in candidates if c.similarity_score >= threshold] + review = [c for c in candidates if REVIEW_THRESHOLD <= c.similarity_score < threshold] + + if dry_run: + logger.info( + "DRY RUN: would auto-accept %d, review queue %d", + len(auto), + len(review), + ) + return {"auto_accepted": 0, "review_queue": len(review), "skipped": 0} + + inserted = 0 + skipped = 0 + for c in auto: + try: + with db.begin_nested(): + result = db.execute( + text( + """ + INSERT INTO objective_complex_mapping + (objective_complex_name, domrf_obj_id, objective_group, + match_method, match_score, is_reviewed) + VALUES ( + CAST(:name AS text), + CAST(:obj_id AS bigint), + CAST(:group AS text), + CAST(:method AS text), + CAST(:score AS numeric), + CAST(:reviewed AS boolean) + ) + ON CONFLICT (objective_complex_name, objective_group) DO NOTHING + """ + ), + { + "name": c.objective_project_name, + "obj_id": c.domrf_obj_id, + "group": "Екатеринбург", + "method": "fuzzy_trgm", + "score": c.similarity_score, + "reviewed": False, + }, + ) + if result.rowcount > 0: + inserted += 1 + else: + skipped += 1 + except Exception as e: + logger.warning( + "Insert failed для %s ↔ %s: %s", + c.domrf_comm_name, + c.objective_project_name, + e, + ) + skipped += 1 + + db.commit() + logger.info( + "Backfill complete: auto_accepted=%d skipped=%d review_queue=%d", + inserted, + skipped, + len(review), + ) + return {"auto_accepted": inserted, "review_queue": len(review), "skipped": skipped} + + +def trigger_mv_refresh(db: Session) -> int: + """REFRESH mv_layout_velocity после backfill. + + Вызывает существующий helper из layout_velocity_refresh. + Передаём concurrently=True (MV уже заполнен). + """ + from app.services.site_finder.layout_velocity_refresh import refresh_layout_velocity + + return refresh_layout_velocity(db, concurrently=True) diff --git a/backend/tests/services/test_objective_backfill.py b/backend/tests/services/test_objective_backfill.py new file mode 100644 index 00000000..3da13880 --- /dev/null +++ b/backend/tests/services/test_objective_backfill.py @@ -0,0 +1,176 @@ +"""Тесты для objective_backfill.py (#203). + +Использует mock DB (unittest.mock) — не требует реального PostgreSQL. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock, patch + +from app.services.etl.objective_backfill import ( + AUTO_ACCEPT_THRESHOLD, + REVIEW_THRESHOLD, + MatchCandidate, + auto_apply_matches, + find_match_candidates, + trigger_mv_refresh, +) + +# ── Helpers ─────────────────────────────────────────────────────────────────── + + +def _make_candidate( + obj_id: int, + comm_name: str, + project_name: str, + score: float, + dev_name: str | None = "ООО Девелопер", +) -> MatchCandidate: + return MatchCandidate( + domrf_obj_id=obj_id, + domrf_comm_name=comm_name, + domrf_dev_name=dev_name, + objective_project_name=project_name, + similarity_score=score, + ) + + +def _make_db_row(obj_id: int, comm_name: str, dev_name: str, project: str, score: float) -> Any: + """Имитирует Row, возвращаемый SQLAlchemy db.execute().all().""" + return (obj_id, comm_name, dev_name, project, score) + + +# ── Test 1: find_match_candidates возвращает правильные MatchCandidate ──────── + + +def test_find_match_candidates_returns_candidates() -> None: + """find_match_candidates корректно преобразует DB rows в MatchCandidate.""" + mock_rows = [ + _make_db_row(100, "Северный квартал", "Брусника", "Северный квартал", 1.0), + _make_db_row(200, "АЛЛЕГРО", "СЗ ГОРЖИЛСТРОЙ", "АЛЛЕГРО", 0.95), + _make_db_row(300, "Некий ЖК", None, "Близкий ЖК", 0.72), + ] + + mock_execute = MagicMock() + mock_execute.all.return_value = mock_rows + mock_db = MagicMock() + mock_db.execute.return_value = mock_execute + + candidates = find_match_candidates(mock_db, only_unmapped=True) + + assert len(candidates) == 3 + assert candidates[0].domrf_obj_id == 100 + assert candidates[0].similarity_score == 1.0 + assert candidates[0].domrf_dev_name == "Брусника" + assert candidates[2].domrf_dev_name is None # NULL dev_name → None + assert candidates[2].similarity_score == 0.72 + + # Убедимся, что db.execute вызывался с параметрами + mock_db.execute.assert_called_once() + call_kwargs = mock_db.execute.call_args[0][1] + assert call_kwargs["only_unmapped"] is True + assert call_kwargs["min_threshold"] == REVIEW_THRESHOLD + + +# ── Test 2: auto_apply_matches dry_run не вставляет ────────────────────────── + + +def test_auto_apply_matches_dry_run_no_inserts() -> None: + """dry_run=True возвращает счётчики без обращения к БД (execute не вызывается).""" + mock_db = MagicMock() + + candidates = [ + _make_candidate(1, "ЖК А", "ЖК А", 0.95), # auto-accept + _make_candidate(2, "ЖК Б", "ЖК Б", 0.70), # review queue + _make_candidate(3, "ЖК В", "ЖК В", 0.65), # review queue + ] + + result = auto_apply_matches(mock_db, candidates, dry_run=True) + + assert result["auto_accepted"] == 0 + assert result["review_queue"] == 2 + mock_db.execute.assert_not_called() + mock_db.commit.assert_not_called() + + +# ── Test 3: auto_apply_matches high-score inserts, low-score → review_queue ── + + +def test_auto_apply_matches_inserts_high_score_only() -> None: + """Только кандидаты >= AUTO_ACCEPT_THRESHOLD вставляются в БД.""" + # Мокируем begin_nested как context manager + savepoint_cm = MagicMock() + savepoint_cm.__enter__ = MagicMock(return_value=None) + savepoint_cm.__exit__ = MagicMock(return_value=False) + + mock_execute_result = MagicMock() + mock_execute_result.rowcount = 1 + + mock_db = MagicMock() + mock_db.begin_nested.return_value = savepoint_cm + mock_db.execute.return_value = mock_execute_result + + high_score = AUTO_ACCEPT_THRESHOLD # ровно на пороге — должен вставляться + low_score = REVIEW_THRESHOLD # ровно REVIEW_THRESHOLD — не auto-accept + + candidates = [ + _make_candidate(10, "ЖК Альфа", "ЖК Альфа", high_score), + _make_candidate(11, "ЖК Бета", "ЖК Бета", high_score + 0.05), + _make_candidate(20, "ЖК Гамма", "ЖК Гамма", low_score), # review только + ] + + result = auto_apply_matches(mock_db, candidates, threshold=AUTO_ACCEPT_THRESHOLD) + + assert result["auto_accepted"] == 2 + assert result["review_queue"] == 1 + assert mock_db.execute.call_count == 2 # вызывался только для high-score + mock_db.commit.assert_called_once() + + +# ── Test 4: trigger_mv_refresh делегирует в layout_velocity_refresh ────────── + + +def test_trigger_mv_refresh_calls_helper() -> None: + """trigger_mv_refresh вызывает refresh_layout_velocity с concurrently=True. + + refresh_layout_velocity импортируется лениво внутри trigger_mv_refresh, + поэтому патчим по полному пути модуля-источника. + """ + mock_db = MagicMock() + + with patch( + "app.services.site_finder.layout_velocity_refresh.refresh_layout_velocity", + return_value=512, + ) as mock_refresh: + count = trigger_mv_refresh(mock_db) + + assert count == 512 + mock_refresh.assert_called_once_with(mock_db, concurrently=True) + + +# ── Test 5: ON CONFLICT — rowcount=0 считается как skipped ─────────────────── + + +def test_auto_apply_matches_conflict_counted_as_skipped() -> None: + """Если INSERT вернул rowcount=0 (ON CONFLICT DO NOTHING), считается skipped.""" + savepoint_cm = MagicMock() + savepoint_cm.__enter__ = MagicMock(return_value=None) + savepoint_cm.__exit__ = MagicMock(return_value=False) + + mock_execute_result = MagicMock() + mock_execute_result.rowcount = 0 # ON CONFLICT DO NOTHING + + mock_db = MagicMock() + mock_db.begin_nested.return_value = savepoint_cm + mock_db.execute.return_value = mock_execute_result + + candidates = [ + _make_candidate(99, "Уже существующий ЖК", "Уже существующий ЖК", 0.99), + ] + + result = auto_apply_matches(mock_db, candidates) + + assert result["auto_accepted"] == 0 + assert result["skipped"] == 1 + assert result["review_queue"] == 0 diff --git a/data/sql/97_objective_backfill_indexes.sql b/data/sql/97_objective_backfill_indexes.sql new file mode 100644 index 00000000..13a0b968 --- /dev/null +++ b/data/sql/97_objective_backfill_indexes.sql @@ -0,0 +1,31 @@ +-- 97_objective_backfill_indexes.sql +-- Issue #203 — Backfill objective_complex_mapping (114 → ~1500 EKB ЖК). +-- +-- pg_trgm уже установлен в prod, но CREATE EXTENSION IF NOT EXISTS идемпотентен. +-- Trigram GIST index на comm_name (DOM.РФ) ускоряет LATERAL similarity() join +-- при поиске кандидатов для fuzzy match (domrf_kn_objects ↔ objective_corpus_room_month). +-- +-- Dependencies: domrf_kn_objects, objective_corpus_room_month (уже существуют). +-- Applied: automatically via deploy.yml in NN order. + +BEGIN; + +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +-- Trigram GIST index для fuzzy match comm_name (DOM.РФ) ↔ project_name (Objective). +-- Фильтр is_ekb = true: только ЕКБ объекты (~1285 строк). +CREATE INDEX IF NOT EXISTS idx_domrf_kn_objects_comm_name_trgm + ON domrf_kn_objects USING gist (comm_name gist_trgm_ops) + WHERE is_ekb = true; + +-- B-tree index на project_name в objective для JOIN performance. +CREATE INDEX IF NOT EXISTS idx_objective_corpus_room_month_project_name + ON objective_corpus_room_month (project_name); + +COMMENT ON INDEX idx_domrf_kn_objects_comm_name_trgm IS + 'Trigram GIST index для fuzzy match с objective_complex_mapping (#203 backfill)'; + +COMMENT ON INDEX idx_objective_corpus_room_month_project_name IS + 'B-tree index на project_name для JOIN performance (#203 backfill)'; + +COMMIT; From ce2c48fff56c957268105d41fc09175fcb4220c0 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 16 May 2026 12:54:51 +0000 Subject: [PATCH 017/342] feat(site-finder): debounce weight re-analyze (#201 Phase 2) (#215) --- frontend/src/app/site-finder/page.tsx | 76 +++++++++++++++++++++---- frontend/src/hooks/useDebouncedValue.ts | 19 +++++++ 2 files changed, 83 insertions(+), 12 deletions(-) create mode 100644 frontend/src/hooks/useDebouncedValue.ts diff --git a/frontend/src/app/site-finder/page.tsx b/frontend/src/app/site-finder/page.tsx index 5f914ca2..baa15870 100644 --- a/frontend/src/app/site-finder/page.tsx +++ b/frontend/src/app/site-finder/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { Suspense, useEffect, useState } from "react"; +import { Suspense, useEffect, useRef, useState } from "react"; import dynamic from "next/dynamic"; import Link from "next/link"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; @@ -15,6 +15,7 @@ import { LandTab } from "@/components/site-finder/LandTab"; import { MarketTab } from "@/components/site-finder/MarketTab"; import { WeightProfilePanel } from "@/components/site-finder/WeightProfilePanel"; import { useSiteAnalysis } from "@/hooks/useSiteAnalysis"; +import { useDebouncedValue } from "@/hooks/useDebouncedValue"; import { POI_DEFAULT_WEIGHTS, type PoiCategoryKey, @@ -104,6 +105,18 @@ function SiteFinderContent() { >(() => ({ ...POI_DEFAULT_WEIGHTS })); // Profile selection for the analyze call (null = system defaults). const [activeProfileId, setActiveProfileId] = useState(null); + + // Pending weights change — set immediately when user clicks "Применить". + // Debounced to 300ms before triggering re-analyze, so rapid clicks collapse + // into a single request (#201 Phase 2). + const [pendingWeightsChange, setPendingWeightsChange] = useState<{ + weights: Record; + profileId: number | null; + } | null>(null); + const debouncedWeightsChange = useDebouncedValue(pendingWeightsChange, 300); + // Ref to skip the initial mount effect (we only re-analyze on actual changes). + const weightsChangeInitializedRef = useRef(false); + const [profileUserId, setProfileUserId] = useState(() => typeof window === "undefined" ? "" @@ -144,6 +157,40 @@ function SiteFinderContent() { setTabState(isTabId(urlTab) ? urlTab : "overview"); }, [searchParams]); + // Debounced re-analyze trigger: fires 300ms after the last "Применить" click. + // Skip initial mount (weightsChangeInitializedRef guards the first effect run). + // dataRef lets the effect read current data without listing it as a dep + // (avoids re-registering the timeout on every analyze completion). + const dataRef = useRef(data); + dataRef.current = data; + const profileUserIdRef = useRef(profileUserId); + profileUserIdRef.current = profileUserId; + + useEffect(() => { + if (!weightsChangeInitializedRef.current) { + weightsChangeInitializedRef.current = true; + return; + } + if (!debouncedWeightsChange) return; + const currentData = dataRef.current; + if (!currentData?.cad_num) return; + + const { weights, profileId } = debouncedWeightsChange; + const currentProfileUserId = profileUserIdRef.current; + setIsochrones(undefined); + mutate({ + cad: currentData.cad_num, + options: + profileId != null + ? { profileId } + : currentProfileUserId + ? { profileUserId: currentProfileUserId, weights } + : { weights }, + }); + // mutate is stable from useMutation — safe to omit from deps. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedWeightsChange]); + function handleAnalyze(cadNum: string) { setIsochrones(undefined); setTab("overview"); @@ -168,18 +215,10 @@ function SiteFinderContent() { ) { setCurrentWeights(weights); setActiveProfileId(profileId); - // Re-analyze with the new weights if a parcel is already loaded (#201). + // Enqueue a debounced re-analyze if a parcel is loaded (#201 Phase 2). + // The actual mutate fires after 300ms of silence via debouncedWeightsChange. if (data?.cad_num) { - setIsochrones(undefined); - mutate({ - cad: data.cad_num, - options: - profileId != null - ? { profileId } - : profileUserId - ? { profileUserId, weights } - : { weights }, - }); + setPendingWeightsChange({ weights, profileId }); } } @@ -304,6 +343,19 @@ function SiteFinderContent() { userId={profileUserId || undefined} adminToken={adminToken || undefined} /> + {/* Recalculation indicator — shown while re-analyze is in-flight after + weights change (data already loaded, pendingWeightsChange set). */} + {isPending && !!data && ( +

+ Пересчёт… +

+ )}
{/* Error state */} diff --git a/frontend/src/hooks/useDebouncedValue.ts b/frontend/src/hooks/useDebouncedValue.ts new file mode 100644 index 00000000..4e737639 --- /dev/null +++ b/frontend/src/hooks/useDebouncedValue.ts @@ -0,0 +1,19 @@ +"use client"; + +import { useEffect, useState } from "react"; + +/** + * Returns a debounced copy of `value` that only updates after `delayMs` + * milliseconds of silence. Useful to collapse rapid state changes before + * triggering expensive side-effects (e.g. API calls). + */ +export function useDebouncedValue(value: T, delayMs: number = 300): T { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const handle = setTimeout(() => setDebounced(value), delayMs); + return () => clearTimeout(handle); + }, [value, delayMs]); + + return debounced; +} From 76b1f8673ffb082c751c9958b44bd9f3381304d3 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 16 May 2026 13:07:13 +0000 Subject: [PATCH 018/342] test(infra): integration phantom column gate (#197) (#216) --- backend/pyproject.toml | 1 + backend/tests/integration/__init__.py | 0 backend/tests/integration/conftest.py | 153 +++++++ .../tests/integration/test_phantom_columns.py | 379 ++++++++++++++++++ 4 files changed, 533 insertions(+) create mode 100644 backend/tests/integration/__init__.py create mode 100644 backend/tests/integration/conftest.py create mode 100644 backend/tests/integration/test_phantom_columns.py diff --git a/backend/pyproject.toml b/backend/pyproject.toml index dbbbb3b0..3a42d9ed 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -83,4 +83,5 @@ addopts = ["-m", "not prod_smoke"] markers = [ "slow: marks tests as slow (need real network, deselect with -m 'not slow')", "prod_smoke: production smoke tests against live https://gendsgn.ru (run only post-deploy with -m prod_smoke)", + "integration: phantom column gate tests requiring TEST_DATABASE_URL (SSH tunnel to prod Postgres)", ] diff --git a/backend/tests/integration/__init__.py b/backend/tests/integration/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/integration/conftest.py b/backend/tests/integration/conftest.py new file mode 100644 index 00000000..1ad9c920 --- /dev/null +++ b/backend/tests/integration/conftest.py @@ -0,0 +1,153 @@ +"""Integration test fixtures для phantom column gate. + +Использует реальный PostgreSQL через SSH-туннель (localhost:15432). +Тесты пропускаются если TEST_DATABASE_URL не задан. + +Подход (Option B): временная schema в prod-Postgres (не трогаем данные). + 1. CREATE SCHEMA test_phantom_ + 2. Копируем DDL public → временная schema через pg_dump --schema-only + 3. Выполняем EXPLAIN-запросы (не EXECUTE — readonly) + 4. DROP SCHEMA CASCADE после теста + +Credentials: TEST_DATABASE_URL (env var, только через SSH-туннель). +Пример: postgresql+psycopg://user:pass@localhost:15432/gendesign +""" + +from __future__ import annotations + +import logging +import os +import random +import string +import subprocess +from collections.abc import Generator + +import pytest +from sqlalchemy import create_engine, text +from sqlalchemy.orm import Session, sessionmaker + +logger = logging.getLogger(__name__) + +_TEST_DATABASE_URL = os.environ.get("TEST_DATABASE_URL", "") + +# Используется во всех тестах этого пакета: пропускаем без TEST_DATABASE_URL +_SKIP_REASON = "TEST_DATABASE_URL не задан — интеграционные тесты требуют SSH-туннель" + +requires_test_db = pytest.mark.skipif( + not _TEST_DATABASE_URL, + reason=_SKIP_REASON, +) + + +def _random_suffix(n: int = 8) -> str: + return "".join(random.choices(string.ascii_lowercase + string.digits, k=n)) + + +def _pg_dump_schema_only(db_url: str, schema: str = "public") -> str: + """Получить DDL через pg_dump --schema-only --schema=. + + Возвращает SQL как строку. Требует pg_dump в PATH. + Credentials извлекаются из db_url. + + Fallback: если pg_dump недоступен — возвращает пустую строку, + и тогда тест работает без создания отдельной schema (тестирует прямо в public). + """ + try: + result = subprocess.run( + [ + "pg_dump", + "--schema-only", + f"--schema={schema}", + "--no-owner", + "--no-acl", + db_url.replace("+psycopg", ""), # pg_dump не понимает +psycopg dialect + ], + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode == 0: + return result.stdout + logger.warning("pg_dump failed (rc=%d): %s", result.returncode, result.stderr[:200]) + except FileNotFoundError: + logger.warning("pg_dump не найден в PATH — phantom gate будет тестировать в public schema") + except subprocess.TimeoutExpired: + logger.warning("pg_dump timeout — phantom gate будет тестировать в public schema") + return "" + + +@pytest.fixture(scope="session") +def phantom_schema_name() -> str: + """Имя временной schema для phantom column тестов.""" + return f"test_phantom_{_random_suffix()}" + + +@pytest.fixture(scope="session") +def phantom_check_session(phantom_schema_name: str) -> Generator[Session, None, None]: + """SQLAlchemy Session, настроенная на временную schema. + + Если pg_dump доступен — создаёт временную schema с копией DDL из public. + Если нет — работает напрямую с public schema (EXPLAIN не требует изменения данных). + После завершения сессии — DROP SCHEMA CASCADE. + + Пропускается если TEST_DATABASE_URL не задан. + """ + if not _TEST_DATABASE_URL: + pytest.skip(_SKIP_REASON) + + engine = create_engine( + _TEST_DATABASE_URL, + # Без пула — это одноразовая тестовая сессия + pool_pre_ping=True, + pool_size=1, + max_overflow=0, + echo=False, + ) + session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) + + # Попробуем создать временную schema с копией DDL + ddl_sql = _pg_dump_schema_only(_TEST_DATABASE_URL) + use_temp_schema = bool(ddl_sql) + schema = phantom_schema_name if use_temp_schema else "public" + # Track schema creation independently — needed for teardown even if DDL apply fails + schema_created = False + + if use_temp_schema: + with engine.connect() as conn: + conn.execute(text(f'CREATE SCHEMA IF NOT EXISTS "{schema}"')) + conn.commit() + schema_created = True + ddl_for_schema = ddl_sql.replace( + "SET search_path = public", f'SET search_path = "{schema}"' + ).replace("search_path TO public", f'search_path TO "{schema}"') + prefixed_ddl = f'SET search_path TO "{schema}", public;\n' + ddl_for_schema + try: + conn.execute(text(prefixed_ddl)) + conn.commit() + except Exception as e: + logger.warning("DDL apply to temp schema failed: %s — falling back to public", e) + use_temp_schema = False + schema = "public" + conn.rollback() + + db = session_factory() + # Устанавливаем search_path для session: temp schema первая, public как fallback + # (pg_dump DDL содержит объекты только под этой schema) + if use_temp_schema: + db.execute(text(f'SET search_path TO "{schema}", public')) + else: + db.execute(text("SET search_path TO public")) + + try: + yield db + finally: + db.close() + # Drop temp schema if it was created — even if DDL apply later failed. + # Otherwise schemas leak when CREATE SCHEMA succeeds but DDL apply raises. + if schema_created: + drop_target = phantom_schema_name + with engine.connect() as conn: + conn.execute(text(f'DROP SCHEMA IF EXISTS "{drop_target}" CASCADE')) + conn.commit() + logger.info("phantom gate: dropped temp schema %s", drop_target) + engine.dispose() diff --git a/backend/tests/integration/test_phantom_columns.py b/backend/tests/integration/test_phantom_columns.py new file mode 100644 index 00000000..0f395661 --- /dev/null +++ b/backend/tests/integration/test_phantom_columns.py @@ -0,0 +1,379 @@ +"""Phantom column gate — EXPLAIN-тесты против реальной Postgres schema. + +Каждый тест выполняет EXPLAIN (не EXECUTE!) на SQL из production services. +Если колонка не существует — Postgres бросит ошибку при планировании → тест fail. +Если таблица не существует — аналогично. + +Запуск: + # Без TEST_DATABASE_URL — все тесты skip + uv run pytest tests/integration/ -v + + # С SSH-туннелем (ssh -N gendesign → localhost:15432): + export TEST_DATABASE_URL="postgresql+psycopg://user:pass@localhost:15432/db" + uv run pytest tests/integration/test_phantom_columns.py -v -m integration + +Детектируемые классы багов: + - Ссылка на несуществующую колонку (domrf_kn_objects.geom_3857 — PR #196) + - Ссылка на несуществующую колонку (ekburg_construction_permits.units_count — PR #213) + - Ссылка на несуществующую таблицу / view + - Опечатки в именах колонок и таблиц +""" + +from __future__ import annotations + +import pytest +from sqlalchemy import text +from sqlalchemy.orm import Session + +from tests.integration.conftest import requires_test_db + +# ── Маркеры ─────────────────────────────────────────────────────────────────── + +pytestmark = [ + requires_test_db, + pytest.mark.integration, +] + + +# ── helpers ─────────────────────────────────────────────────────────────────── + + +def _explain(db: Session, query: str, params: dict | None = None) -> None: + """Выполнить EXPLAIN для query. Не выполняет DML — только план запроса. + + Бросает исключение (тест fail) если: + - колонка не существует + - таблица/view не существует + - синтаксическая ошибка SQL + """ + explain_sql = f"EXPLAIN {query}" + db.execute(text(explain_sql), params or {}) + + +# ── 1. domrf_kn_objects — реальные колонки ──────────────────────────────────── + + +class TestDomrfKnObjects: + """Проверяем что SQL в services используют реальные колонки domrf_kn_objects. + + Исторический баг PR #196: geom_3857 не существует. + Реальная schema: latitude, longitude (float), snapshot_date, obj_class, etc. + """ + + def test_competitors_radius_query(self, phantom_check_session: Session) -> None: + """best_layouts._COMPETITORS_IN_RADIUS_SQL — latitude/longitude/snapshot_date.""" + _explain( + phantom_check_session, + """ + SELECT DISTINCT ON (obj_id) obj_id + FROM domrf_kn_objects + WHERE latitude IS NOT NULL AND longitude IS NOT NULL + AND ST_DWithin( + ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)::geography, + ST_SetSRID( + ST_MakePoint(CAST(60.6 AS float), CAST(56.8 AS float)), 4326 + )::geography, + CAST(1000 AS float) + ) + ORDER BY obj_id, snapshot_date DESC NULLS LAST + """, + ) + + def test_competitors_full_cte_query(self, phantom_check_session: Session) -> None: + """competitors._COMPETITORS_SQL — полный CTE с latitude/longitude/flat_count.""" + _explain( + phantom_check_session, + """ + 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 + ), + 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(60.6 AS float), CAST(56.8 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 + FROM distances d + WHERE d.distance_m <= CAST(1000 AS float) + ORDER BY d.distance_m ASC + """, + ) + + def test_velocity_competitor_query_columns(self, phantom_check_session: Session) -> None: + """velocity._competitors query — district_name, region_cd, snapshot_date.""" + _explain( + phantom_check_session, + """ + SELECT DISTINCT ON (obj_id) + obj_id, + comm_name, + dev_name, + obj_class, + latitude, + longitude, + district_name + FROM domrf_kn_objects + WHERE latitude IS NOT NULL + AND longitude IS NOT NULL + AND region_cd = 66 + ORDER BY obj_id, snapshot_date DESC NULLS LAST + """, + ) + + +# ── 2. ekburg_construction_permits — реальные колонки ──────────────────────── + + +class TestEkburgConstructionPermits: + """Проверяем что SQL используют реальные колонки ekburg_construction_permits. + + Исторический баг PR #213: units_count не существует. + Реальная schema: total_area_sqm, living_area_sqm, living_area_fact_sqm, etc. + """ + + def test_recent_permits_query(self, phantom_check_session: Session) -> None: + """parcels.py recent_permits query — total_area_sqm (не units_count!).""" + _explain( + phantom_check_session, + """ + SELECT + permit_type, permit_number, issue_date, + developer_name, developer_inn, object_name, object_type, + construction_address, total_area_sqm + FROM ekburg_construction_permits + WHERE LEFT(cadastral_number, LENGTH(CAST('66:41:0303161' AS text))) + = CAST('66:41:0303161' AS text) + AND issue_date > NOW() - INTERVAL '24 months' + ORDER BY issue_date DESC + LIMIT 50 + """, + ) + + def test_permits_rns_columns(self, phantom_check_session: Session) -> None: + """Все колонки RNS-специфичные: living_area_sqm, permit_type.""" + _explain( + phantom_check_session, + """ + SELECT + permit_type, + permit_number, + issue_date, + expiry_date, + developer_name, + developer_inn, + object_name, + object_type, + construction_address, + cadastral_number, + total_area_sqm, + living_area_sqm + FROM ekburg_construction_permits + WHERE permit_type = 'RNS' + LIMIT 1 + """, + ) + + def test_permits_rve_columns(self, phantom_check_session: Session) -> None: + """Все колонки RVE-специфичные: living_area_fact_sqm, rve_number, rve_date.""" + _explain( + phantom_check_session, + """ + SELECT + permit_type, + permit_number, + living_area_fact_sqm, + rve_number, + rve_date + FROM ekburg_construction_permits + WHERE permit_type = 'RVE' + LIMIT 1 + """, + ) + + +# ── 3. mv_layout_velocity — materialized view ──────────────────────────────── + + +class TestMvLayoutVelocity: + """Проверяем materialized view mv_layout_velocity и её колонки.""" + + def test_velocity_by_room_bucket(self, phantom_check_session: Session) -> None: + """best_layouts._VELOCITY_BY_ROOM_SQL — room_bucket, total_deals_24mo, etc.""" + _explain( + phantom_check_session, + """ + SELECT + room_bucket, + SUM(total_deals_24mo) AS sum_deals, + AVG(avg_area_m2) AS avg_area_m2, + AVG(avg_price_thousand_rub_per_m2) * 1000.0 AS avg_price_per_m2_rub, + array_agg(DISTINCT obj_id) AS competitor_obj_ids, + COUNT(DISTINCT obj_id) AS competitor_count, + MIN(window_start) AS window_start, + MAX(window_end) AS window_end + FROM mv_layout_velocity + WHERE obj_id = ANY(ARRAY[1, 2, 3]) + GROUP BY room_bucket + """, + ) + + +# ── 4. domrf_kn_flats ───────────────────────────────────────────────────────── + + +class TestDomrfKnFlats: + """Проверяем таблицу domrf_kn_flats и её колонки.""" + + def test_supply_batch_query(self, phantom_check_session: Session) -> None: + """best_layouts._SUPPLY_BATCH_SQL — total_area, rooms, is_studio, flat_type.""" + _explain( + phantom_check_session, + """ + SELECT + CASE + WHEN f.is_studio = TRUE OR f.flat_type = 'Квартира-студия' THEN 'studio' + WHEN f.rooms = 0 THEN 'studio' + WHEN f.rooms IN (1, 2, 3) THEN f.rooms::text + WHEN f.rooms >= 4 THEN '4+' + ELSE '1' + END AS rb, + CASE + WHEN f.total_area < 25 THEN '<25' + WHEN f.total_area < 40 THEN '25-40' + WHEN f.total_area < 60 THEN '40-60' + WHEN f.total_area < 80 THEN '60-80' + WHEN f.total_area < 100 THEN '80-100' + ELSE '100+' + END AS ab, + COUNT(*) AS units + FROM domrf_kn_flats f + JOIN domrf_kn_objects o ON f.obj_id = o.obj_id + WHERE o.latitude IS NOT NULL AND o.longitude IS NOT NULL + AND f.snapshot_date = CAST('2026-01-01' AS date) + GROUP BY rb, ab + """, + ) + + def test_avg_price_sold_query(self, phantom_check_session: Session) -> None: + """competitors._AVG_PRICE_SQL — price_per_m2, status='sold'.""" + _explain( + phantom_check_session, + """ + SELECT + f.obj_id, + AVG(f.price_per_m2) AS avg_price_per_m2 + FROM domrf_kn_flats f + WHERE f.obj_id = ANY(ARRAY[1, 2, 3]) + AND f.price_per_m2 IS NOT NULL + AND f.status = 'sold' + GROUP BY f.obj_id + """, + ) + + +# ── 5. objective_complex_mapping / objective_corpus_room_month ──────────────── + + +class TestObjectiveTables: + """Проверяем objective_* таблицы и их колонки.""" + + def test_objective_mapping_columns(self, phantom_check_session: Session) -> None: + """competitors / velocity: domrf_obj_id, objective_complex_name в mapping.""" + _explain( + phantom_check_session, + """ + SELECT cm.domrf_obj_id AS obj_id, + cm.objective_complex_name + FROM objective_complex_mapping cm + LIMIT 1 + """, + ) + + def test_objective_corpus_room_month_columns(self, phantom_check_session: Session) -> None: + """velocity._VELOCITY_BY_ROOM_SQL — deals_total_count, deals_total_vol_m2.""" + _explain( + phantom_check_session, + """ + SELECT + m.obj_id, + SUM(COALESCE(crm.deals_total_vol_m2, + crm.deals_total_count * 45.0)) AS total_sqm, + COUNT(DISTINCT crm.report_month) AS months_with_data, + MIN(crm.report_month) AS period_start, + MAX(crm.report_month) AS period_end + FROM objective_corpus_room_month crm + JOIN ( + SELECT cm.domrf_obj_id AS obj_id, + cm.objective_complex_name + FROM objective_complex_mapping cm + WHERE cm.domrf_obj_id = ANY(ARRAY[1, 2, 3]) + ) m + ON m.objective_complex_name = crm.project_name + WHERE crm.report_month >= (CURRENT_DATE - CAST('12 months' AS interval)) + AND crm.deals_total_count > 0 + GROUP BY m.obj_id + """, + ) + + +# ── 6. cad geo таблицы ──────────────────────────────────────────────────────── + + +class TestCadGeoTables: + """Проверяем cad_parcels_geom, cad_quarters_geom и их колонки.""" + + def test_parcel_centroid_query(self, phantom_check_session: Session) -> None: + """best_layouts._PARCEL_CENTROID_SQL — cad_num, cad_number, geom.""" + _explain( + phantom_check_session, + """ + SELECT ST_X(pt) AS center_lon, + ST_Y(pt) AS center_lat + FROM ( + SELECT ST_Centroid(geom) AS pt + FROM cad_parcels_geom + WHERE cad_num = '66:41:0303161:123' AND geom IS NOT NULL + UNION ALL + SELECT ST_Centroid(geom) AS pt + FROM cad_quarters_geom + WHERE cad_number = '66:41:0303161' AND geom IS NOT NULL + ) sub + LIMIT 1 + """, + ) From 1595157ebfa972d914385a369e53460adb04cab3 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 16 May 2026 16:11:59 +0300 Subject: [PATCH 019/342] feat(nspd): enable TIER 3 risk zones harvest + extract in analyze (#94 PR1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - harvest_stale_quarters fanout passes include_risks=True to all queued tasks - _get_risk_zones() with ST_Intersects + ST_Area intersection calc - _extract_features_by_layer() generic helper for future PR2+ reuse - RiskZone Pydantic schema; /analyze returns nspd_risk_zones list - NspdRiskZonesBlock component with severity color-coding (red/yellow/orange) - 11 unit tests for extract/intersect/null-area/db-exception paths Part of #94 (TIER 3 risk layers — flooding, landslide, burns, erosion etc) --- backend/app/api/v1/parcels.py | 3 + backend/app/schemas/parcel.py | 12 ++ .../site_finder/quarter_dump_lookup.py | 157 +++++++++++++- backend/app/workers/tasks/nspd_sync.py | 5 +- .../services/test_quarter_dump_lookup.py | 200 ++++++++++++++++++ .../src/components/site-finder/LandTab.tsx | 14 +- .../site-finder/NspdRiskZonesBlock.tsx | 147 +++++++++++++ frontend/src/types/nspd.ts | 9 + frontend/src/types/site-finder.ts | 1 + 9 files changed, 545 insertions(+), 3 deletions(-) create mode 100644 backend/tests/services/test_quarter_dump_lookup.py create mode 100644 frontend/src/components/site-finder/NspdRiskZonesBlock.tsx diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index d16458c5..5dd72961 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -24,6 +24,7 @@ from app.schemas.parcel import ( ParcelDetail, ParcelSearchRequest, ParcelSearchResponse, + RiskZone, ) from app.services.exporters.layout_tz_pdf import render_layout_tz_pdf from app.services.site_finder.best_layouts import get_best_layouts @@ -2063,10 +2064,12 @@ def analyze_parcel( # nspd_zoning: ПЗЗ зона из territorial_zones кеша (G1) # nspd_zouit_overlaps: ЗОУИТ пересечения (G3) # nspd_engineering_nearby: инженерные сооружения в 200м (I3) + # nspd_risk_zones: TIER 3 risk zones (#94) — подтопление, эрозия, гари, оползни # nspd_dump: freshness metadata — available, stale, harvest_triggered "nspd_zoning": nspd_dump_data["nspd_zoning"], "nspd_zouit_overlaps": nspd_dump_data["nspd_zouit_overlaps"], "nspd_engineering_nearby": nspd_dump_data["nspd_engineering_nearby"], + "nspd_risk_zones": [RiskZone(**rz) for rz in nspd_dump_data.get("nspd_risk_zones", [])], "nspd_dump": nspd_dump_data["nspd_dump"], # #32 G5: gate verdict — can-build-MKD aggregated signal for UI banner "gate_verdict": compute_gate_verdict( diff --git a/backend/app/schemas/parcel.py b/backend/app/schemas/parcel.py index 7da0ce4b..48990eff 100644 --- a/backend/app/schemas/parcel.py +++ b/backend/app/schemas/parcel.py @@ -68,6 +68,18 @@ class ConnectionPointsResponse(BaseModel): dump_fetched_at: str | None +# ── NSPD Risk Zones schemas (issue #94 TIER 3) ─────────────────────────────── + + +class RiskZone(BaseModel): + """Одна риск-зона НСПД (TIER 3), пересекающая участок.""" + + layer: str # e.g. "risk_flooding", "risk_landslide" + subtype: str | None # человекочитаемое название из NSPD properties + geom_wkt: str | None # WKT геометрии risk-зоны в EPSG:4326 + intersection_area_sqm: float | None # площадь пересечения с участком, м² + + # ── Parcel search/detail schemas ────────────────────────────────────────────── diff --git a/backend/app/services/site_finder/quarter_dump_lookup.py b/backend/app/services/site_finder/quarter_dump_lookup.py index ce5d9213..d44d9136 100644 --- a/backend/app/services/site_finder/quarter_dump_lookup.py +++ b/backend/app/services/site_finder/quarter_dump_lookup.py @@ -15,6 +15,7 @@ from __future__ import annotations import json import logging +import math from datetime import UTC, datetime, timedelta from typing import Any @@ -37,6 +38,7 @@ EMPTY_DUMP_RESULT: dict[str, Any] = { "nspd_zoning": None, "nspd_zouit_overlaps": [], "nspd_engineering_nearby": [], + "nspd_risk_zones": [], "nspd_dump": { "available": False, "fetched_at_utc": None, @@ -62,6 +64,7 @@ def make_empty_result( "nspd_zoning": None, "nspd_zouit_overlaps": [], "nspd_engineering_nearby": [], + "nspd_risk_zones": [], "nspd_dump": { "available": False, "fetched_at_utc": fetched_at_utc, @@ -124,7 +127,8 @@ def get_quarter_dump_data( harvest_error, territorial_zones_count, zouit_count, - engineering_count + engineering_count, + risks_count FROM nspd_quarter_dumps WHERE quarter_cad = :q """ @@ -150,6 +154,7 @@ def get_quarter_dump_data( territorial_zones_count: int = row[4] or 0 zouit_count: int = row[5] or 0 engineering_count: int = row[6] or 0 + risks_count: int = row[7] or 0 is_stale = (now - fetched_at) > max_age has_error = harvest_error is not None @@ -179,6 +184,7 @@ def get_quarter_dump_data( "nspd_zoning": None, "nspd_zouit_overlaps": [], "nspd_engineering_nearby": [], + "nspd_risk_zones": [], "nspd_dump": dump_meta, } @@ -186,15 +192,18 @@ def get_quarter_dump_data( "territorial_zones_count": territorial_zones_count, "zouit_count": zouit_count, "engineering_count": engineering_count, + "risks_count": risks_count, } nspd_zoning = _get_zoning(db, quarter, parcel_wkt, layer_counts) nspd_zouit = _get_zouit_overlaps(db, quarter, parcel_wkt, layer_counts) nspd_engineering = _get_engineering_nearby(db, quarter, parcel_wkt, layer_counts) + nspd_risk_zones = _get_risk_zones(db, quarter, parcel_wkt, layer_counts) return { "nspd_zoning": nspd_zoning, "nspd_zouit_overlaps": nspd_zouit, "nspd_engineering_nearby": nspd_engineering, + "nspd_risk_zones": nspd_risk_zones, "nspd_dump": dump_meta, } @@ -394,6 +403,152 @@ def _get_engineering_nearby( return [] +# ── Risk zones (issue #94 TIER 3) ──────────────────────────────────────────── + +# Human-readable subtype labels for risk layer keys. +# Keys match NSPDClient.QUARTER_RISK_LAYERS short_names. +_RISK_SUBTYPE_LABELS: dict[str, str] = { + "flooding_underground": "Подтопление", + "flooding": "Затопление", + "swampification": "Заболачивание", + "landslide": "Обвально-осыпные процессы", + "abrasion": "Абразия", + "erosion_water": "Водная эрозия", + "erosion_linear": "Линейная эрозия", + "erosion_wind": "Ветровая эрозия", + "desertification": "Опустынивание", + "clutter": "Захламление", + "burns": "Гари", +} + + +def _extract_features_by_layer( + features: list[dict[str, Any]], + layer_prefix: str, +) -> list[dict[str, Any]]: + """Generic filter: вернуть features у которых layer начинается с layer_prefix. + + Предназначен для re-use в PR 2+ (opportunity layers, etc.). + + Args: + features: list of feature dicts из features_json (уже загруженных в Python). + layer_prefix: префикс для фильтрации, e.g. 'risk_', 'zouit_'. + + Returns: + Подсписок features где feat['layer'].startswith(layer_prefix). + """ + return [ + f + for f in features + if isinstance(f.get("layer"), str) and f["layer"].startswith(layer_prefix) + ] + + +def _get_risk_zones( + db: Session, + quarter: str, + parcel_wkt: str, + layer_counts: dict[str, int] | None = None, +) -> list[dict[str, Any]]: + """TIER 3: риск-зоны НСПД (11 слоёв), пересекающие участок. + + Возвращает список риск-зон с полями: + layer, subtype, geom_wkt, intersection_area_sqm. + + layer_counts — денормализованные счётчики. Если risks_count == 0 — + пропускаем heavy jsonb_array_elements scan. + """ + if layer_counts is not None and layer_counts.get("risks_count", 1) == 0: + return [] + try: + rows = db.execute( + text( + """ + SELECT feat.value->>'layer' AS layer, + feat.value->'properties' AS props, + ST_AsText( + ST_Transform( + ST_SetSRID( + ST_GeomFromGeoJSON(feat.value->>'geometry'), + 3857 + ), + 4326 + ) + ) AS geom_wkt, + ST_Area( + ST_Intersection( + ST_Transform( + ST_SetSRID( + ST_GeomFromGeoJSON(feat.value->>'geometry'), + 3857 + ), + 4326 + )::geography, + ST_GeomFromText(:wkt, 4326)::geography + ) + ) AS intersection_area_sqm + FROM nspd_quarter_dumps d, + jsonb_array_elements(d.features_json) AS feat(value) + WHERE d.quarter_cad = :q + AND feat.value->>'layer' LIKE 'risk_%' + AND (feat.value->'geometry') IS NOT NULL + AND feat.value->>'geometry' != 'null' + AND ST_Intersects( + ST_Transform( + ST_SetSRID( + ST_GeomFromGeoJSON(feat.value->>'geometry'), + 3857 + ), + 4326 + ), + ST_GeomFromText(:wkt, 4326) + ) + ORDER BY layer, intersection_area_sqm DESC NULLS LAST + """ + ), + {"q": quarter, "wkt": parcel_wkt}, + ).fetchall() + + result: list[dict[str, Any]] = [] + for r in rows: + layer: str = r[0] or "" + props: dict[str, Any] = r[1] if isinstance(r[1], dict) else {} + geom_wkt_val: str | None = r[2] + raw_area: Any = r[3] + intersection_area_sqm: float | None = None + if raw_area is not None: + try: + val = float(raw_area) + intersection_area_sqm = round(val, 1) if not math.isnan(val) else None + except (TypeError, ValueError): + pass + + # Извлечь short_name из "risk_" для человекочитаемого subtype + short_name = layer.removeprefix("risk_") + # Попытка взять subtype из properties, затем из mapping + subtype = ( + props.get("type_zone") + or props.get("subcategory") + or props.get("name") + or _RISK_SUBTYPE_LABELS.get(short_name) + ) + if subtype is not None: + subtype = str(subtype) + + result.append( + { + "layer": layer, + "subtype": subtype, + "geom_wkt": geom_wkt_val, + "intersection_area_sqm": intersection_area_sqm, + } + ) + return result + except Exception as e: + logger.warning("nspd risk zones query failed for quarter=%s: %s", quarter, e) + return [] + + # ── Connection-points lookup (issue #115) ──────────────────────────────────── diff --git a/backend/app/workers/tasks/nspd_sync.py b/backend/app/workers/tasks/nspd_sync.py index dc7eda85..f4eec507 100644 --- a/backend/app/workers/tasks/nspd_sync.py +++ b/backend/app/workers/tasks/nspd_sync.py @@ -389,7 +389,10 @@ def harvest_stale_quarters( enqueued = 0 for cad in stale_cads: try: - harvest_quarter.apply_async(args=[cad, region_code]) + harvest_quarter.apply_async( + args=[cad, region_code], + kwargs={"include_zouit": True, "include_risks": True}, + ) enqueued += 1 except Exception as e: logger.warning("harvest_stale_quarters: enqueue failed for cad=%s: %s", cad, e) diff --git a/backend/tests/services/test_quarter_dump_lookup.py b/backend/tests/services/test_quarter_dump_lookup.py new file mode 100644 index 00000000..244b7a9e --- /dev/null +++ b/backend/tests/services/test_quarter_dump_lookup.py @@ -0,0 +1,200 @@ +"""Тесты для quarter_dump_lookup.py — risk zones + generic layer extraction. + +Покрывает: +- _extract_features_by_layer: filter by prefix +- _get_risk_zones / extract through get_quarter_dump_data: intersect, no-intersect, + no-risks-in-dump +Не требует реального PostgreSQL — мокает db.execute(). +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from app.services.site_finder.quarter_dump_lookup import ( + _extract_features_by_layer, + _get_risk_zones, + derive_quarter_cad, + make_empty_result, +) + +# ── Fixtures & helpers ──────────────────────────────────────────────────────── + + +def _make_feature(layer: str, geom: dict[str, Any] | None = None) -> dict[str, Any]: + """Минимальный feature dict в формате features_json.""" + return { + "layer": layer, + "feature_id": "test-id", + "geometry": geom or {"type": "Polygon", "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 0]]]}, + "properties": {"name": f"test-{layer}", "type_zone": f"zone-{layer}"}, + } + + +# ── _extract_features_by_layer ──────────────────────────────────────────────── + + +def test_extract_features_by_layer_prefix_match() -> None: + """Возвращает только features с нужным prefix.""" + features = [ + _make_feature("risk_flooding"), + _make_feature("risk_burns"), + _make_feature("zouit_engineering"), + _make_feature("parcels"), + _make_feature("risk_landslide"), + ] + result = _extract_features_by_layer(features, "risk_") + assert len(result) == 3 + layers = {f["layer"] for f in result} + assert layers == {"risk_flooding", "risk_burns", "risk_landslide"} + + +def test_extract_features_by_layer_zouit_prefix() -> None: + """Работает для zouit_ prefix — generic re-use.""" + features = [ + _make_feature("zouit_engineering"), + _make_feature("zouit_okn"), + _make_feature("risk_flooding"), + ] + result = _extract_features_by_layer(features, "zouit_") + assert len(result) == 2 + assert all(f["layer"].startswith("zouit_") for f in result) + + +def test_extract_features_by_layer_empty_list() -> None: + result = _extract_features_by_layer([], "risk_") + assert result == [] + + +def test_extract_features_by_layer_no_match() -> None: + features = [_make_feature("parcels"), _make_feature("buildings")] + result = _extract_features_by_layer(features, "risk_") + assert result == [] + + +def test_extract_features_by_layer_non_string_layer() -> None: + """Gracefully skips features with non-string layer.""" + features: list[dict[str, Any]] = [ + {"layer": None, "feature_id": "x", "geometry": None, "properties": {}}, + _make_feature("risk_flooding"), + ] + result = _extract_features_by_layer(features, "risk_") + assert len(result) == 1 + assert result[0]["layer"] == "risk_flooding" + + +# ── _get_risk_zones ─────────────────────────────────────────────────────────── + + +def _make_mock_db_with_rows(rows: list[tuple[Any, ...]]) -> MagicMock: + """Мок Session где execute().fetchall() возвращает rows.""" + db = MagicMock() + mock_result = MagicMock() + mock_result.fetchall.return_value = rows + db.execute.return_value = mock_result + return db + + +def test_get_risk_zones_intersects() -> None: + """Три risk features → три записи в результате.""" + rows: list[tuple[Any, ...]] = [ + # (layer, props, geom_wkt, intersection_area_sqm) + ( + "risk_flooding", + {"type_zone": "Затопление 1%"}, + "POLYGON((0 0,1 0,1 1,0 0))", + 1234.5, + ), + ( + "risk_burns", + {"name": "Гарь"}, + "POLYGON((0 0,2 0,2 2,0 0))", + 500.0, + ), + ( + "risk_landslide", + {}, + "POLYGON((0 0,3 0,3 3,0 0))", + 999.9, + ), + ] + db = _make_mock_db_with_rows(rows) + + result = _get_risk_zones(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))") + + assert len(result) == 3 + + flooding = next(r for r in result if r["layer"] == "risk_flooding") + assert flooding["subtype"] == "Затопление 1%" + assert flooding["intersection_area_sqm"] == 1234.5 + assert flooding["geom_wkt"] == "POLYGON((0 0,1 0,1 1,0 0))" + + burns = next(r for r in result if r["layer"] == "risk_burns") + assert burns["subtype"] == "Гарь" + + landslide = next(r for r in result if r["layer"] == "risk_landslide") + # No properties name/type_zone → falls back to _RISK_SUBTYPE_LABELS + assert landslide["subtype"] == "Обвально-осыпные процессы" + + +def test_get_risk_zones_no_intersect() -> None: + """Пустой fetchall → пустой список.""" + db = _make_mock_db_with_rows([]) + result = _get_risk_zones(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))") + assert result == [] + + +def test_get_risk_zones_no_risks_in_dump() -> None: + """risks_count == 0 → early exit, db.execute не вызывается.""" + db = MagicMock() + layer_counts = {"risks_count": 0} + result = _get_risk_zones(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))", layer_counts) + assert result == [] + db.execute.assert_not_called() + + +def test_get_risk_zones_db_exception_returns_empty() -> None: + """DB exception → пустой список (не propagate).""" + db = MagicMock() + db.execute.side_effect = Exception("DB connection lost") + result = _get_risk_zones(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))") + assert result == [] + + +def test_get_risk_zones_null_area() -> None: + """NULL intersection_area_sqm → intersection_area_sqm=None в результате.""" + rows = [("risk_erosion_water", {}, "POINT(0 0)", None)] + db = _make_mock_db_with_rows(rows) + result = _get_risk_zones(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))") + assert len(result) == 1 + assert result[0]["intersection_area_sqm"] is None + + +# ── make_empty_result includes nspd_risk_zones ──────────────────────────────── + + +def test_make_empty_result_has_risk_zones_key() -> None: + """make_empty_result возвращает nspd_risk_zones: [].""" + result = make_empty_result() + assert "nspd_risk_zones" in result + assert result["nspd_risk_zones"] == [] + + +# ── derive_quarter_cad (smoke tests) ────────────────────────────────────────── + + +@pytest.mark.parametrize( + "cad,expected", + [ + ("66:41:0204016", "66:41:0204016"), + ("66:41:0204016:10", "66:41:0204016"), + ("66:41:0204016:10:1", "66:41:0204016"), + ("invalid", None), + ("66:41", None), + ], +) +def test_derive_quarter_cad(cad: str, expected: str | None) -> None: + assert derive_quarter_cad(cad) == expected diff --git a/frontend/src/components/site-finder/LandTab.tsx b/frontend/src/components/site-finder/LandTab.tsx index e72b10ad..601b1ebd 100644 --- a/frontend/src/components/site-finder/LandTab.tsx +++ b/frontend/src/components/site-finder/LandTab.tsx @@ -10,6 +10,7 @@ import { NeighborsBlock } from "./NeighborsBlock"; import { NspdZoningBlock } from "./NspdZoningBlock"; import { NspdZouitOverlapsBlock } from "./NspdZouitOverlapsBlock"; import { NspdEngineeringNearbyBlock } from "./NspdEngineeringNearbyBlock"; +import { NspdRiskZonesBlock } from "./NspdRiskZonesBlock"; import { NspdFreshnessBadge } from "./NspdFreshnessBadge"; interface Props { @@ -24,7 +25,8 @@ export function LandTab({ data }: Props) { data.neighbors_summary !== undefined || data.nspd_zoning !== undefined || data.nspd_zouit_overlaps !== undefined || - data.nspd_engineering_nearby !== undefined; + data.nspd_engineering_nearby !== undefined || + data.nspd_risk_zones !== undefined; return (
@@ -68,6 +70,16 @@ export function LandTab({ data }: Props) {
)} + {/* Issue #94 TIER 3 — Риск-зоны НСПД */} + {data.nspd_risk_zones !== undefined && ( +
+ + Природные риски (НСПД) + + +
+ )} + {/* Issue #202 — Инженерные объекты в 200 м */} {data.nspd_engineering_nearby !== undefined && (
diff --git a/frontend/src/components/site-finder/NspdRiskZonesBlock.tsx b/frontend/src/components/site-finder/NspdRiskZonesBlock.tsx new file mode 100644 index 00000000..2c45c1bf --- /dev/null +++ b/frontend/src/components/site-finder/NspdRiskZonesBlock.tsx @@ -0,0 +1,147 @@ +"use client"; + +import type { RiskZone } from "@/types/nspd"; + +interface Props { + riskZones: RiskZone[] | null | undefined; + parcelAreaSqm?: number | null; +} + +// Severity mapping: layer key suffix → severity tier +function getSeverity(layer: string): "high" | "medium" | "low" { + const key = layer.replace(/^risk_/, ""); + if (key === "flooding" || key === "landslide") return "high"; + if (key === "flooding_underground" || key === "burns") return "medium"; + return "low"; +} + +const SEVERITY_STYLES: Record< + "high" | "medium" | "low", + { bg: string; badgeBg: string; color: string; label: string } +> = { + high: { + bg: "#fff1f2", + badgeBg: "#fee2e2", + color: "#991b1b", + label: "ВЫСОКИЙ", + }, + medium: { + bg: "#fffbeb", + badgeBg: "#fef3c7", + color: "#92400e", + label: "СРЕДНИЙ", + }, + low: { + bg: "#fff7ed", + badgeBg: "#ffedd5", + color: "#9a3412", + label: "НИЗКИЙ", + }, +}; + +function formatArea(sqm: number | null): string | null { + if (sqm === null) return null; + if (sqm >= 10000) return `${(sqm / 10000).toFixed(2)} га`; + return `${Math.round(sqm).toLocaleString("ru-RU")} м²`; +} + +export function NspdRiskZonesBlock({ riskZones, parcelAreaSqm }: Props) { + const zones = riskZones ?? []; + + if (zones.length === 0) { + return ( +
+ + Риски не обнаружены + + + Риск-зоны НСПД на участке не выявлены + +
+ ); + } + + // Sort: high first, then medium, then low + const sorted = [...zones].sort((a, b) => { + const order = { high: 0, medium: 1, low: 2 }; + return order[getSeverity(a.layer)] - order[getSeverity(b.layer)]; + }); + + return ( +
+ {sorted.map((zone, idx) => { + const severity = getSeverity(zone.layer); + const style = SEVERITY_STYLES[severity]; + const areaStr = formatArea(zone.intersection_area_sqm); + const pct = + parcelAreaSqm && zone.intersection_area_sqm + ? Math.min( + 100, + Math.round((zone.intersection_area_sqm / parcelAreaSqm) * 100), + ) + : null; + + return ( +
+ + {style.label} + +
+
+ {zone.subtype ?? zone.layer} +
+ {(areaStr || pct !== null) && ( +
+ {areaStr && Площадь пересечения: {areaStr}} + {areaStr && pct !== null && · } + {pct !== null && {pct}% участка} +
+ )} +
+
+ ); + })} +
+ ); +} diff --git a/frontend/src/types/nspd.ts b/frontend/src/types/nspd.ts index daf84416..024f5313 100644 --- a/frontend/src/types/nspd.ts +++ b/frontend/src/types/nspd.ts @@ -32,6 +32,15 @@ export interface NspdDumpMeta { total_features: number | null; } +// Risk zones (issue #94 TIER 3) — from /analyze nspd_risk_zones field + +export interface RiskZone { + layer: string; // e.g. "risk_flooding", "risk_landslide" + subtype: string | null; // human-readable label from NSPD properties or mapping + geom_wkt: string | null; + intersection_area_sqm: number | null; +} + // Connection-points endpoint shapes (/api/v1/parcels/{cad}/connection-points) export interface EngineeringStructure { diff --git a/frontend/src/types/site-finder.ts b/frontend/src/types/site-finder.ts index 2af9ee13..3d66b542 100644 --- a/frontend/src/types/site-finder.ts +++ b/frontend/src/types/site-finder.ts @@ -383,6 +383,7 @@ export interface ParcelAnalysis { nspd_zoning?: import("./nspd").NspdZoning | null; nspd_zouit_overlaps?: import("./nspd").NspdZouitOverlap[]; nspd_engineering_nearby?: import("./nspd").NspdEngineeringNearby[]; + nspd_risk_zones?: import("./nspd").RiskZone[]; nspd_dump?: import("./nspd").NspdDumpMeta | null; } From d3aa96336ba362fe7a7d37adb11390e97e1ba6d0 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 16 May 2026 15:08:50 +0000 Subject: [PATCH 020/342] feat(infra): GlitchTip self-hosted error tracking (#204 devops) (#205) --- .env.example | 7 ++ Caddyfile | 12 ++++ docker-compose.prod.yml | 51 ++++++++++++++ scripts/bootstrap_glitchtip.sh | 123 +++++++++++++++++++++++++++++++++ 4 files changed, 193 insertions(+) create mode 100644 scripts/bootstrap_glitchtip.sh diff --git a/.env.example b/.env.example index fcfba003..1b16ba67 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,10 @@ POSTGRES_PASSWORD=changeme # YC_REGISTRY_ID= # IMAGE_TAG=latest # NEXT_PUBLIC_API_BASE_URL=https://your-domain.example.ru + +# GlitchTip (errors.gendsgn.ru) +GLITCHTIP_DB_PASS= +GLITCHTIP_SECRET= +# DSN-ы из GlitchTip UI после первого старта (→ .env.runtime или backend/.env) +GLITCHTIP_DSN= +NEXT_PUBLIC_GLITCHTIP_DSN= diff --git a/Caddyfile b/Caddyfile index 5c52cc04..4b43b2ed 100644 --- a/Caddyfile +++ b/Caddyfile @@ -44,6 +44,18 @@ obsidian.gendsgn.ru { } } +# GlitchTip — self-hosted error tracking (Sentry-compatible). +# DNS: A-record errors.gendsgn.ru → IP VPS. +errors.gendsgn.ru { + encode zstd gzip + + reverse_proxy glitchtip-web:8080 + + log { + output file /var/log/caddy/errors.gendsgn.ru.log + } +} + # Plain HTTP by IP — kept for ssh-tunnel / debugging. # Caddy issues no TLS here (no hostname). :80 { diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 90632581..e4d8f3a0 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -121,6 +121,57 @@ services: condition: service_healthy command: ["celery", "-A", "app.workers.celery_app", "beat", "--loglevel=info"] + glitchtip-web: + image: glitchtip/glitchtip:6.1.6 + container_name: glitchtip-web + # profiles: ["glitchtip"] keeps this service from starting on plain `compose up -d`. + # Bootstrap script activates the profile after DB + secrets are ready. + # On subsequent deploys, set COMPOSE_PROFILES=glitchtip in /opt/gendesign/.env. + profiles: ["glitchtip"] + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_started + environment: + DATABASE_URL: postgres://glitchtip:${GLITCHTIP_DB_PASS}@postgres:5432/glitchtip + REDIS_URL: redis://redis:6379/2 + SECRET_KEY: ${GLITCHTIP_SECRET} + PORT: "8080" + EMAIL_URL: dummy:// + GLITCHTIP_DOMAIN: https://errors.gendsgn.ru + DEFAULT_FROM_EMAIL: errors@gendsgn.ru + ENABLE_USER_REGISTRATION: "false" + ENABLE_ORGANIZATION_CREATION: "false" + restart: always + mem_limit: 512m + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/api/0/"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 60s + networks: [default] + + glitchtip-worker: + image: glitchtip/glitchtip:6.1.6 + container_name: glitchtip-worker + profiles: ["glitchtip"] + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_started + command: ./bin/run-celery-with-beat.sh + environment: + DATABASE_URL: postgres://glitchtip:${GLITCHTIP_DB_PASS}@postgres:5432/glitchtip + REDIS_URL: redis://redis:6379/2 + SECRET_KEY: ${GLITCHTIP_SECRET} + CELERY_WORKER_AUTOSCALE: "1,3" + restart: always + mem_limit: 384m + networks: [default] + caddy: image: caddy:2 restart: unless-stopped diff --git a/scripts/bootstrap_glitchtip.sh b/scripts/bootstrap_glitchtip.sh new file mode 100644 index 00000000..007bb5df --- /dev/null +++ b/scripts/bootstrap_glitchtip.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# bootstrap_glitchtip.sh +# One-shot bootstrap for GlitchTip on the VPS. +# +# Run ONCE manually after merging feat/204-glitchtip-infra. +# Do NOT add to deploy.yml — this is idempotent setup, not a recurring step. +# +# Usage (on VPS via SSH): +# cd /opt/gendesign +# # Ensure GLITCHTIP_DB_PASS and GLITCHTIP_SECRET are set in .env +# bash scripts/bootstrap_glitchtip.sh +# +# After running: +# 1. docker compose -p gendesign -f docker-compose.prod.yml exec glitchtip-web ./manage.py createsuperuser +# 2. Log in to https://errors.gendsgn.ru → create org "gendesign" +# 3. Create projects: "backend", "frontend" +# 4. Copy DSNs → add to /opt/gendesign/backend/.env (GLITCHTIP_DSN) +# and /opt/gendesign/frontend/.env or .env.runtime (NEXT_PUBLIC_GLITCHTIP_DSN) +# 5. Recreate affected containers: +# docker compose -p gendesign -f docker-compose.prod.yml up -d --force-recreate --no-deps backend + +set -euo pipefail + +COMPOSE_FILE="docker-compose.prod.yml" +COMPOSE_PROJECT="gendesign" +COMPOSE="docker compose -p $COMPOSE_PROJECT -f $COMPOSE_FILE" + +# ── Load .env.runtime for GLITCHTIP_DB_PASS / POSTGRES_* ────────────────── +if [ -f ".env" ]; then + set -a; source .env; set +a +fi +if [ -f "backend/.env.runtime" ]; then + set -a; source backend/.env.runtime; set +a +fi + +: "${GLITCHTIP_DB_PASS:?GLITCHTIP_DB_PASS not set — add to .env or export}" +: "${POSTGRES_USER:?POSTGRES_USER not set}" +: "${POSTGRES_PASSWORD:?POSTGRES_PASSWORD not set}" +: "${POSTGRES_DB:?POSTGRES_DB not set}" + +echo "==> Ensuring postgres service is healthy..." +for i in $(seq 1 30); do + if $COMPOSE exec -T postgres pg_isready -U "$POSTGRES_USER" -q; then + break + fi + echo " Waiting for postgres... ($i/30)" + sleep 2 +done + +# ── Create glitchtip DB + user (idempotent) ──────────────────────────────── +# Password is passed via psql variable -v to avoid SQL injection from special +# chars (quotes, backslashes) in $GLITCHTIP_DB_PASS. format('%L', :'gt_pass') +# properly quotes the value server-side, so the shell never interpolates it +# into the SQL string. +echo "==> Creating glitchtip database and user (idempotent)..." +$COMPOSE exec -T postgres \ + psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" \ + -v "gt_pass=$GLITCHTIP_DB_PASS" \ + -v ON_ERROR_STOP=on <<'SQL' +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'glitchtip') THEN + EXECUTE format('CREATE ROLE glitchtip LOGIN PASSWORD %L', :'gt_pass'); + RAISE NOTICE 'Created role glitchtip'; + ELSE + RAISE NOTICE 'Role glitchtip already exists'; + END IF; +END$$; + +SELECT 'CREATE DATABASE glitchtip OWNER glitchtip' +WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'glitchtip') +\gexec +SQL + +# ── Start GlitchTip containers ───────────────────────────────────────────── +# glitchtip-web and glitchtip-worker carry `profiles: ["glitchtip"]` in compose, +# so plain `up -d` skips them. Activate the profile explicitly here. +# After bootstrap succeeds, add COMPOSE_PROFILES=glitchtip to /opt/gendesign/.env +# so that future automatic deploys also bring these services up. +echo "==> Pulling glitchtip image..." +$COMPOSE --profile glitchtip pull glitchtip-web glitchtip-worker + +echo "==> Starting glitchtip-web and glitchtip-worker..." +$COMPOSE --profile glitchtip up -d glitchtip-web glitchtip-worker + +echo "" +echo "NOTE: Add the following line to /opt/gendesign/.env so future deploys" +echo " keep GlitchTip running:" +echo " COMPOSE_PROFILES=glitchtip" + +# ── Wait for glitchtip-web to become healthy ─────────────────────────────── +# Use wget (available in glitchtip image) matching the compose healthcheck probe. +echo "==> Waiting for glitchtip-web to become healthy (up to 60s)..." +for i in $(seq 1 30); do + if $COMPOSE exec -T glitchtip-web wget -q --spider http://localhost:8080/api/0/ >/dev/null 2>&1; then + echo " glitchtip-web is healthy." + break + fi + echo " Waiting... ($i/30)" + sleep 2 +done + +echo "" +echo "==> Bootstrap complete." +echo "" +echo "Next steps:" +echo " 1. Create superuser:" +echo " $COMPOSE exec glitchtip-web ./manage.py createsuperuser" +echo "" +echo " 2. Log in to https://errors.gendsgn.ru" +echo " - Create organization: gendesign" +echo " - Create project: backend (Platform: Python)" +echo " - Create project: frontend (Platform: JavaScript / Browser)" +echo "" +echo " 3. Copy DSNs from each project's Settings → DSN" +echo " - Add GLITCHTIP_DSN= to /opt/gendesign/backend/.env" +echo " - Add NEXT_PUBLIC_GLITCHTIP_DSN= to /opt/gendesign/backend/.env or .env" +echo "" +echo " 4. Recreate services to pick up new DSNs:" +echo " $COMPOSE up -d --force-recreate --no-deps backend" +echo " $COMPOSE up -d --force-recreate --no-deps worker beat" +echo "" +echo " 5. Verify: trigger a test exception and confirm it appears in GlitchTip." From b61f025673a08c9ae9c1b6b9f4346a876e854b4b Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 16 May 2026 15:09:16 +0000 Subject: [PATCH 021/342] =?UTF-8?q?feat(backend):=20sentry-sdk=20init=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20FastAPI=20+=20Celery=20(#204=20backend)=20?= =?UTF-8?q?(#207)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.env.example | 8 +- backend/app/core/config.py | 34 ++++- backend/app/main.py | 45 +++++- backend/app/observability/__init__.py | 0 backend/app/observability/sentry_scrub.py | 49 +++++++ backend/app/workers/celery_app.py | 31 +++++ backend/pyproject.toml | 2 +- backend/tests/test_sentry_init.py | 158 ++++++++++++++++++++++ backend/uv.lock | 13 +- 9 files changed, 323 insertions(+), 17 deletions(-) create mode 100644 backend/app/observability/__init__.py create mode 100644 backend/app/observability/sentry_scrub.py create mode 100644 backend/tests/test_sentry_init.py diff --git a/backend/.env.example b/backend/.env.example index f6b3c987..32bd1d6e 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -6,8 +6,12 @@ REDIS_URL=redis://redis:6379/0 CORS_ORIGINS=["http://localhost:3000"] ENVIRONMENT=dev -# Sentry: create a project at https://sentry.io/, paste the DSN here. -# Empty string = Sentry disabled. main.py initializes only if non-empty. +# DSN для GlitchTip (https://errors.gendsgn.ru). Пустое = SDK no-op. +# Формат: https://@errors.gendsgn.ru/ +GLITCHTIP_DSN= +GLITCHTIP_TRACES_SAMPLE_RATE=0.05 +# DEPRECATED — будет удалён после 1-2 deploy-циклов. Использовать GLITCHTIP_DSN. +# Если задан, а GLITCHTIP_DSN пуст — автоматически продвигается с DeprecationWarning. SENTRY_DSN= # External APIs (Stage 2) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 69f9b1ff..16c29b34 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -1,3 +1,7 @@ +import os +import warnings + +from pydantic import model_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -7,13 +11,33 @@ class Settings(BaseSettings): database_url: str = "postgresql+psycopg://gendesign:gendesign@localhost:5432/gendesign" redis_url: str = "redis://localhost:6379/0" cors_origins: list[str] = ["http://localhost:3000"] - sentry_dsn: str | None = None - # Release tag для Sentry — обычно git short sha, проставляется - # deploy.yml в backend/.env.runtime (см. workflow). Локально оставляем - # пустым — Sentry припишет 'unknown'. - sentry_release: str | None = None + # GlitchTip error tracking (Sentry-compatible self-hosted). + # Формат DSN: https://@errors.gendsgn.ru/ + # Пустая строка / None = SDK не инициализируется (no-op). + glitchtip_dsn: str | None = None + glitchtip_traces_sample_rate: float = 0.05 environment: str = "dev" + @model_validator(mode="after") + def _promote_legacy_sentry_dsn(self) -> "Settings": + """Backward-compat: если SENTRY_DSN задан, а GLITCHTIP_DSN пуст — автопродвигаем. + + На существующем VPS .env.runtime содержит SENTRY_DSN=https://... + (старое имя переменной). Pydantic extra="ignore" молча его отбрасывает, + SDK остаётся no-op. Читаем напрямую из os.environ и выдаём DeprecationWarning. + """ + if not self.glitchtip_dsn: + legacy = os.getenv("SENTRY_DSN") + if legacy: + warnings.warn( + "SENTRY_DSN is set but ignored by pydantic; rename to GLITCHTIP_DSN. " + "Auto-promoting for backward compat.", + DeprecationWarning, + stacklevel=2, + ) + self.glitchtip_dsn = legacy + return self + # External APIs (Stage 2) rosreestr_pkk_base_url: str = "https://pkk.rosreestr.ru/api/features/1" overpass_url: str = "https://overpass-api.de/api/interpreter" diff --git a/backend/app/main.py b/backend/app/main.py index 4197a6b4..a70826ae 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,9 +1,17 @@ +import logging +import os from collections.abc import AsyncIterator from contextlib import asynccontextmanager import sentry_sdk from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from sentry_sdk.integrations.celery import CeleryIntegration +from sentry_sdk.integrations.fastapi import FastApiIntegration +from sentry_sdk.integrations.httpx import HttpxIntegration +from sentry_sdk.integrations.logging import LoggingIntegration +from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration +from sentry_sdk.integrations.starlette import StarletteIntegration from app.api.v1 import ( admin_cadastre, @@ -18,17 +26,40 @@ from app.api.v1 import ( photos, ) from app.core.config import settings +from app.observability.sentry_scrub import scrub_sensitive_query + +logger = logging.getLogger(__name__) + +# Инициализируем SDK до создания FastAPI app, чтобы все инструменты +# (middleware, маршруты) видели активный client с самого старта процесса. +# GlitchTip не поддерживает profiling — profiles_sample_rate=0.0. +if settings.glitchtip_dsn: + sentry_sdk.init( + dsn=settings.glitchtip_dsn, + environment=settings.environment, + release=os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown", + traces_sample_rate=settings.glitchtip_traces_sample_rate, + profiles_sample_rate=0.0, + send_default_pii=False, + before_send_transaction=scrub_sensitive_query, + integrations=[ + StarletteIntegration(), + FastApiIntegration(), + CeleryIntegration(monitor_beat_tasks=True), + SqlalchemyIntegration(), + HttpxIntegration(), + LoggingIntegration(level=logging.INFO, event_level=logging.ERROR), + ], + ) + logger.info( + "GlitchTip SDK initialised (env=%s, traces=%.2f)", + settings.environment, + settings.glitchtip_traces_sample_rate, + ) @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: - if settings.sentry_dsn: - sentry_sdk.init( - dsn=settings.sentry_dsn, - environment=settings.environment, - release=settings.sentry_release, - traces_sample_rate=0.1, - ) yield diff --git a/backend/app/observability/__init__.py b/backend/app/observability/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/app/observability/sentry_scrub.py b/backend/app/observability/sentry_scrub.py new file mode 100644 index 00000000..af936c31 --- /dev/null +++ b/backend/app/observability/sentry_scrub.py @@ -0,0 +1,49 @@ +"""Хук before_send_transaction для GlitchTip/Sentry SDK. + +Redact-ит api keys / tokens из URL-spans перед отправкой — чтобы +секреты (apiKey=..., api_key=..., token=...) не утекали в GlitchTip +через HttpxIntegration performance-spans. +""" + +from __future__ import annotations + +import re +from typing import Any + +from sentry_sdk.types import Event + +_SENSITIVE_PARAM_RE = re.compile( + r"((?:api[_-]?[Kk]ey|token|access[_-]?token|secret)=)([^&\s]+)", + re.IGNORECASE, +) + + +def _redact(value: Any) -> Any: + """Заменить значения чувствительных query-параметров на [REDACTED].""" + if isinstance(value, str): + return _SENSITIVE_PARAM_RE.sub(r"\1[REDACTED]", value) + return value + + +def scrub_sensitive_query(event: Event, _hint: dict[str, Any]) -> Event | None: + """Redact api keys / tokens из URL spans перед отправкой в GlitchTip. + + Обрабатывает: + - span.data["url"], span.data["http.url"], span.data["http.target"] + - span["description"] + - event["request"]["url"] + """ + for span in event.get("spans") or []: + data = span.get("data") + if isinstance(data, dict): + for key in ("url", "http.url", "http.target"): + if key in data: + data[key] = _redact(data[key]) + if "description" in span: + span["description"] = _redact(span["description"]) + + request = event.get("request") + if isinstance(request, dict) and "url" in request: + request["url"] = _redact(request["url"]) + + return event diff --git a/backend/app/workers/celery_app.py b/backend/app/workers/celery_app.py index 4afd7107..9a3afa0c 100644 --- a/backend/app/workers/celery_app.py +++ b/backend/app/workers/celery_app.py @@ -5,13 +5,44 @@ Worker lifecycle hooks (process_init, worker_ready) → app/workers/lifecycle.py """ import logging +import os +import sentry_sdk from celery import Celery +from sentry_sdk.integrations.celery import CeleryIntegration +from sentry_sdk.integrations.httpx import HttpxIntegration +from sentry_sdk.integrations.logging import LoggingIntegration +from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration from app.core.config import settings +from app.observability.sentry_scrub import scrub_sensitive_query logger = logging.getLogger(__name__) +# SDK инициализируется в обоих процессах (FastAPI-сервер и Celery-воркер), +# чтобы события из тасков попадали в GlitchTip. SDK безопасен для двойного +# вызова — повторный sentry_sdk.init() в одном процессе заменяет клиента. +if settings.glitchtip_dsn: + sentry_sdk.init( + dsn=settings.glitchtip_dsn, + environment=settings.environment, + release=os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown", + traces_sample_rate=settings.glitchtip_traces_sample_rate, + profiles_sample_rate=0.0, + send_default_pii=False, + before_send_transaction=scrub_sensitive_query, + integrations=[ + CeleryIntegration(monitor_beat_tasks=True), + SqlalchemyIntegration(), + HttpxIntegration(), + LoggingIntegration(level=logging.INFO, event_level=logging.ERROR), + ], + ) + logger.info( + "GlitchTip SDK initialised in Celery worker (env=%s)", + settings.environment, + ) + celery_app = Celery( "gendesign", broker=settings.redis_url, diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 3a42d9ed..4de67a30 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -27,7 +27,7 @@ dependencies = [ "pandas>=2.2.0", "numpy>=2.0.0", "scikit-learn>=1.5.0", - "sentry-sdk[fastapi]>=2.10.0", + "sentry-sdk[fastapi,celery,sqlalchemy,httpx]>=2.18.0", "rosreestr2coord>=5.0.0", ] diff --git a/backend/tests/test_sentry_init.py b/backend/tests/test_sentry_init.py new file mode 100644 index 00000000..75538a31 --- /dev/null +++ b/backend/tests/test_sentry_init.py @@ -0,0 +1,158 @@ +"""Unit-тесты логики инициализации GlitchTip SDK. + +Проверяем что init-блок в main.py / celery_app.py вызывает sentry_sdk.init() +только при непустом GLITCHTIP_DSN, что release-fallback работает корректно, +и что scrub_sensitive_query redact-ит api keys из URL spans. +""" + +import os +from unittest.mock import patch + +import sentry_sdk + + +def test_sdk_imports_without_error() -> None: + """Все интеграции импортируются без ModuleNotFoundError.""" + from sentry_sdk.integrations.celery import CeleryIntegration + from sentry_sdk.integrations.fastapi import FastApiIntegration + from sentry_sdk.integrations.httpx import HttpxIntegration + from sentry_sdk.integrations.logging import LoggingIntegration + from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration + from sentry_sdk.integrations.starlette import StarletteIntegration + + assert StarletteIntegration() + assert FastApiIntegration() + assert CeleryIntegration() + assert SqlalchemyIntegration() + assert HttpxIntegration() + assert LoggingIntegration() + + +def test_no_sdk_init_when_dsn_empty() -> None: + """Если GLITCHTIP_DSN пустой, sentry_sdk.init() не должен вызываться.""" + with patch("sentry_sdk.init") as mock_init: + glitchtip_dsn = None + if glitchtip_dsn: + sentry_sdk.init(dsn=glitchtip_dsn) + mock_init.assert_not_called() + + +def test_sdk_init_called_when_dsn_set() -> None: + """Если GLITCHTIP_DSN задан, sentry_sdk.init() вызывается с правильными параметрами.""" + dsn = "https://key@errors.gendsgn.ru/1" + with patch("sentry_sdk.init") as mock_init: + glitchtip_dsn = dsn + if glitchtip_dsn: + sentry_sdk.init( + dsn=glitchtip_dsn, + environment="test", + release="unknown", + traces_sample_rate=0.05, + profiles_sample_rate=0.0, + send_default_pii=False, + integrations=[], + ) + mock_init.assert_called_once() + call_kwargs = mock_init.call_args.kwargs + assert call_kwargs["dsn"] == dsn + assert call_kwargs["send_default_pii"] is False + assert call_kwargs["profiles_sample_rate"] == 0.0 + + +# ── release fallback ────────────────────────────────────────────────────────── + + +def test_release_uses_git_sha_when_set() -> None: + """GIT_SHA имеет приоритет над SENTRY_RELEASE.""" + env = {"GIT_SHA": "abc1234", "SENTRY_RELEASE": "v1.0.0"} + with patch.dict(os.environ, env, clear=False): + release = os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown" + assert release == "abc1234" + + +def test_release_falls_back_to_sentry_release() -> None: + """Если GIT_SHA не задан, используется SENTRY_RELEASE.""" + env = {"SENTRY_RELEASE": "v1.2.3"} + with patch.dict(os.environ, env, clear=False): + # Убираем GIT_SHA если он есть + os.environ.pop("GIT_SHA", None) + release = os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown" + assert release == "v1.2.3" + + +def test_release_falls_back_to_unknown() -> None: + """Если ни одна переменная не задана, release='unknown'.""" + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("GIT_SHA", None) + os.environ.pop("SENTRY_RELEASE", None) + release = os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown" + assert release == "unknown" + + +# ── sentry_scrub ────────────────────────────────────────────────────────────── + + +def test_scrub_redacts_apikey_in_span_url() -> None: + """scrub_sensitive_query заменяет apiKey= в span data['url'].""" + from app.observability.sentry_scrub import scrub_sensitive_query + + event: dict = { + "spans": [ + { + "data": { + "url": "https://api.objctv.ru/v2/Report?apiKey=supersecret&group=EKB", + "http.url": "https://api.objctv.ru/v2/Report?api_key=topsecret", + } + } + ] + } + result = scrub_sensitive_query(event, {}) + span_data = result["spans"][0]["data"] + assert "[REDACTED]" in span_data["url"] + assert "supersecret" not in span_data["url"] + assert "[REDACTED]" in span_data["http.url"] + assert "topsecret" not in span_data["http.url"] + + +def test_scrub_redacts_token_in_description() -> None: + """scrub_sensitive_query заменяет token= в span description.""" + from app.observability.sentry_scrub import scrub_sensitive_query + + event: dict = { + "spans": [{"description": "GET https://example.com?token=mysecrettoken&foo=bar"}] + } + result = scrub_sensitive_query(event, {}) + assert "[REDACTED]" in result["spans"][0]["description"] + assert "mysecrettoken" not in result["spans"][0]["description"] + + +def test_scrub_redacts_request_url() -> None: + """scrub_sensitive_query заменяет token в event['request']['url'].""" + from app.observability.sentry_scrub import scrub_sensitive_query + + event: dict = {"request": {"url": "https://example.com?access_token=abc123&other=val"}} + result = scrub_sensitive_query(event, {}) + assert "[REDACTED]" in result["request"]["url"] + assert "abc123" not in result["request"]["url"] + + +def test_scrub_passes_through_clean_event() -> None: + """scrub_sensitive_query не трогает URL без чувствительных параметров.""" + from app.observability.sentry_scrub import scrub_sensitive_query + + event: dict = { + "spans": [{"data": {"url": "https://example.com?foo=bar&page=1"}}], + "request": {"url": "https://example.com/api/health"}, + } + result = scrub_sensitive_query(event, {}) + assert result["spans"][0]["data"]["url"] == "https://example.com?foo=bar&page=1" + assert result["request"]["url"] == "https://example.com/api/health" + + +def test_scrub_handles_missing_spans() -> None: + """scrub_sensitive_query не падает если 'spans' отсутствует.""" + from app.observability.sentry_scrub import scrub_sensitive_query + + event: dict = {"request": {"url": "https://example.com"}} + result = scrub_sensitive_query(event, {}) + assert result["request"]["url"] == "https://example.com" diff --git a/backend/uv.lock b/backend/uv.lock index 1422c133..82b7289a 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -579,7 +579,7 @@ dependencies = [ { name = "redis" }, { name = "rosreestr2coord" }, { name = "scikit-learn" }, - { name = "sentry-sdk", extra = ["fastapi"] }, + { name = "sentry-sdk", extra = ["celery", "fastapi", "httpx", "sqlalchemy"] }, { name = "shapely" }, { name = "sqlalchemy" }, { name = "tenacity" }, @@ -618,7 +618,7 @@ requires-dist = [ { name = "redis", specifier = ">=5.0.0" }, { name = "rosreestr2coord", specifier = ">=5.0.0" }, { name = "scikit-learn", specifier = ">=1.5.0" }, - { name = "sentry-sdk", extras = ["fastapi"], specifier = ">=2.10.0" }, + { name = "sentry-sdk", extras = ["fastapi", "celery", "sqlalchemy", "httpx"], specifier = ">=2.18.0" }, { name = "shapely", specifier = ">=2.0.0" }, { name = "sqlalchemy", specifier = ">=2.0.30" }, { name = "tenacity", specifier = ">=9.0.0" }, @@ -1910,9 +1910,18 @@ wheels = [ ] [package.optional-dependencies] +celery = [ + { name = "celery" }, +] fastapi = [ { name = "fastapi" }, ] +httpx = [ + { name = "httpx" }, +] +sqlalchemy = [ + { name = "sqlalchemy" }, +] [[package]] name = "shapely" From 277499684b32dbe1e5f6139e189c1da5594138a0 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 16 May 2026 15:09:38 +0000 Subject: [PATCH 022/342] =?UTF-8?q?feat(frontend):=20@sentry/nextjs=20init?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20GlitchTip=20(#204=20frontend)=20(#208)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 + frontend/.env.example | 7 + frontend/next.config.ts | 28 +- frontend/package-lock.json | 3520 ++++++++++++++++++++++++++---- frontend/package.json | 1 + frontend/sentry.client.config.ts | 14 + frontend/sentry.edge.config.ts | 6 + frontend/sentry.server.config.ts | 10 + frontend/src/instrumentation.ts | 11 + 9 files changed, 3183 insertions(+), 418 deletions(-) create mode 100644 frontend/sentry.client.config.ts create mode 100644 frontend/sentry.edge.config.ts create mode 100644 frontend/sentry.server.config.ts create mode 100644 frontend/src/instrumentation.ts diff --git a/.gitignore b/.gitignore index 573730ef..c3eb4649 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,10 @@ node_modules/ .next/ frontend/.next/ out/ + +# Sentry CLI config (auto-generated, contains auth tokens) +.sentryclirc +frontend/.sentryclirc # TypeScript incremental build info — must NOT be committed; locally-generated # and changes on every build, which busts Docker build context cache. *.tsbuildinfo diff --git a/frontend/.env.example b/frontend/.env.example index 8a4b22ef..dcbcc93c 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,2 +1,9 @@ NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 NEXT_PUBLIC_MAPBOX_TOKEN= + +# GlitchTip error tracking (errors.gendsgn.ru) +NEXT_PUBLIC_GLITCHTIP_DSN= +GLITCHTIP_AUTH_TOKEN= +SENTRY_ORG=gendesign +SENTRY_PROJECT=frontend +SENTRY_URL=https://errors.gendsgn.ru/ diff --git a/frontend/next.config.ts b/frontend/next.config.ts index dd8fb5a8..449a5d7e 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -1,4 +1,5 @@ import type { NextConfig } from "next"; +import { withSentryConfig } from "@sentry/nextjs"; const nextConfig: NextConfig = { reactStrictMode: true, @@ -47,4 +48,29 @@ const nextConfig: NextConfig = { }, }; -export default nextConfig; +export default withSentryConfig(nextConfig, { + org: process.env.SENTRY_ORG ?? "gendesign", + project: process.env.SENTRY_PROJECT ?? "frontend", + sentryUrl: process.env.SENTRY_URL ?? "https://errors.gendsgn.ru/", + // Only upload source maps when auth token is present (CI builds). + // Without authToken the upload step is silently skipped. + authToken: process.env.GLITCHTIP_AUTH_TOKEN, + silent: !process.env.CI, + widenClientFileUpload: true, + // Delete generated source maps from the build output after upload to + // prevent them from being served publicly. + sourcemaps: { + deleteSourcemapsAfterUpload: true, + }, + // No tunnel route — GlitchTip is not behind a CORS proxy + tunnelRoute: undefined, + // Webpack-specific options + webpack: { + // Treeshake Sentry SDK logger statements from the bundle + treeshake: { + removeDebugLogging: true, + }, + // Not deploying on Vercel — no cron monitors needed + automaticVercelMonitors: false, + }, +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 300b1295..55c1e0d4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "gendesign-frontend", "version": "0.1.0", "dependencies": { + "@sentry/nextjs": "^10.53.1", "@tanstack/react-query": "^5.50.0", "echarts": "^6.0.0", "echarts-for-react": "^3.0.6", @@ -47,6 +48,230 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@emnapi/core": { "dev": true, "optional": true @@ -152,13 +377,6 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/core/node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, "node_modules/@eslint/eslintrc": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", @@ -343,6 +561,87 @@ "node": ">= 0.8.0" } }, + "node_modules/@fastify/otel": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/@fastify/otel/-/otel-0.18.0.tgz", + "integrity": "sha512-3TASCATfw+ctICSb4ymrv7iCm0qJ0N9CarB+CZ7zIJ7KqNbwI5JjyDL1/sxoC0ccTO1Zyd1iQ+oqncPg5FJXaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.212.0", + "@opentelemetry/semantic-conventions": "^1.28.0", + "minimatch": "^10.2.4" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + } + }, + "node_modules/@fastify/otel/node_modules/@opentelemetry/api-logs": { + "version": "0.212.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.212.0.tgz", + "integrity": "sha512-TEEVrLbNROUkYY51sBJGk7lO/OLjuepch8+hmpM6ffMJQ2z/KVCjdHuCFX6fJj8OkJP2zckPjrJzQtXU3IAsFg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@fastify/otel/node_modules/@opentelemetry/instrumentation": { + "version": "0.212.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.212.0.tgz", + "integrity": "sha512-IyXmpNnifNouMOe0I/gX7ENfv2ZCNdYTF0FpCsoBcpbIHzk81Ww9rQTYTnvghszCg7qGrIhNvWC8dhEifgX9Jg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.212.0", + "import-in-the-middle": "^2.0.6", + "require-in-the-middle": "^8.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@fastify/otel/node_modules/import-in-the-middle": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-2.0.6.tgz", + "integrity": "sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw==", + "license": "Apache-2.0", + "dependencies": { + "acorn": "^8.15.0", + "acorn-import-attributes": "^1.9.5", + "cjs-module-lexer": "^2.2.0", + "module-details-from-path": "^1.0.4" + } + }, + "node_modules/@fastify/otel/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@humanfs/node": { "version": "0.16.8", "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", @@ -409,6 +708,62 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@napi-rs/wasm-runtime": { "dev": true, "optional": true @@ -777,6 +1132,483 @@ "node": ">= 10" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.214.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.214.0.tgz", + "integrity": "sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz", + "integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/instrumentation": { + "version": "0.214.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.214.0.tgz", + "integrity": "sha512-MHqEX5Dk59cqVah5LiARMACku7jXSVk9iVDWOea4x3cr7VfdByeDCURK6o1lntT1JS/Tsovw01UJrBhN3/uC5w==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.214.0", + "import-in-the-middle": "^3.0.0", + "require-in-the-middle": "^8.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-amqplib": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.61.0.tgz", + "integrity": "sha512-mCKoyTGfRNisge4br0NpOFSy2Z1NnEW8hbCJdUDdJFHrPqVzc4IIBPA/vX0U+LUcQqrQvJX+HMIU0dbDRe0i0Q==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.214.0", + "@opentelemetry/semantic-conventions": "^1.33.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-connect": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-connect/-/instrumentation-connect-0.57.0.tgz", + "integrity": "sha512-FMEBChnI4FLN5TE9DHwfH7QpNir1JzXno1uz/TAucVdLCyrG0jTrKIcNHt/i30A0M2AunNBCkcd8Ei26dIPKdg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.214.0", + "@opentelemetry/semantic-conventions": "^1.27.0", + "@types/connect": "3.4.38" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-dataloader": { + "version": "0.31.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.31.0.tgz", + "integrity": "sha512-f654tZFQXS5YeLDNb9KySrwtg7SnqZN119FauD7acBoTzuLduaiGTNz88ixcVSOOMGZ+EjJu/RFtx5klObC95g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.214.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-fs": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fs/-/instrumentation-fs-0.33.0.tgz", + "integrity": "sha512-sCZWXGalQ01wr3tAhSR9ucqFJ0phidpAle6/17HVjD6gN8FLmZMK/8sKxdXYHy3PbnlV1P4zeiSVFNKpbFMNLA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.214.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-generic-pool": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-generic-pool/-/instrumentation-generic-pool-0.57.0.tgz", + "integrity": "sha512-orhmlaK+ZIW9hKU+nHTbXrCSXZcH83AescTqmpamHRobRmYSQwRbD0a1odc0yAzuzOtxYiHiXAnpnIpaSSY7Ow==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.214.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-graphql": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-graphql/-/instrumentation-graphql-0.62.0.tgz", + "integrity": "sha512-3YNuLVPUxafXkH1jBAbGsKNsP3XVzcFDhCDCE3OqBwCwShlqQbLMRMFh1T/d5jaVZiGVmSsfof+ICKD2iOV8xg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.214.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-hapi": { + "version": "0.60.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.60.0.tgz", + "integrity": "sha512-aNljZKYrEa7obLAxd1bCEDxF7kzCLGXTuTJZ8lMR9rIVEjmuKBXN1gfqpm/OB//Zc2zP4iIve1jBp7sr3mQV6w==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.214.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-http": { + "version": "0.214.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-http/-/instrumentation-http-0.214.0.tgz", + "integrity": "sha512-FlkDhZDRjDJDcO2LcSCtjRpkal1NJ8y0fBqBhTvfAR3JSYY2jAIj1kSS5IjmEBt4c3aWv+u/lqLuoCDrrKCSKg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.1", + "@opentelemetry/instrumentation": "0.214.0", + "@opentelemetry/semantic-conventions": "^1.29.0", + "forwarded-parse": "2.1.2" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-http/node_modules/@opentelemetry/core": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.1.tgz", + "integrity": "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/instrumentation-kafkajs": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.23.0.tgz", + "integrity": "sha512-4K+nVo+zI+aDz0Z85SObwbdixIbzS9moIuKJaYsdlzcHYnKOPtB7ya8r8Ezivy/GVIBHiKJVq4tv+BEkgOMLaQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.214.0", + "@opentelemetry/semantic-conventions": "^1.30.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-knex": { + "version": "0.58.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.58.0.tgz", + "integrity": "sha512-Hc/o8fSsaWxZ8r1Yw4rNDLwTpUopTf4X32y4W6UhlHmW8Wizz8wfhgOKIelSeqFVTKBBPIDUOsQWuIMxBmu8Bw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.214.0", + "@opentelemetry/semantic-conventions": "^1.33.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-koa": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.62.0.tgz", + "integrity": "sha512-uVip0VuGUQXZ+vFxkKxAUNq8qNl+VFlyHDh/U6IQ8COOEDfbEchdaHnpFrMYF3psZRUuoSIgb7xOeXj00RdwDA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.214.0", + "@opentelemetry/semantic-conventions": "^1.36.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + } + }, + "node_modules/@opentelemetry/instrumentation-lru-memoizer": { + "version": "0.58.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-lru-memoizer/-/instrumentation-lru-memoizer-0.58.0.tgz", + "integrity": "sha512-6grM3TdMyHzlGY1cUA+mwoPueB1F3dYKgKtZIH6jOFXqfHAByyLTc+6PFjGM9tKh52CFBJaDwodNlL/Td39z7Q==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.214.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-mongodb": { + "version": "0.67.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.67.0.tgz", + "integrity": "sha512-1WJp5N1lYfHq2IhECOTewFs5Tf2NfUOwQRqs/rZdXKTezArMlucxgzAaqcgp3A3YREXopXTpXHsxZTGHjNhMdQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.214.0", + "@opentelemetry/semantic-conventions": "^1.33.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-mongoose": { + "version": "0.60.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.60.0.tgz", + "integrity": "sha512-8BahAZpKsOoc+lrZGb7Ofn4g3z8qtp5IxDfvAVpKXsEheQN7ONMH5djT5ihy6yf8yyeQJGS0gXFfpEAEeEHqQg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.214.0", + "@opentelemetry/semantic-conventions": "^1.33.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-mysql": { + "version": "0.60.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.60.0.tgz", + "integrity": "sha512-08pO8GFPEIz2zquKDGteBZDNmwketdgH8hTe9rVYgW9kCJXq1Psj3wPQGx+VaX4ZJKCfPeoLMYup9+cxHvZyVQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.214.0", + "@opentelemetry/semantic-conventions": "^1.33.0", + "@types/mysql": "2.15.27" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-mysql2": { + "version": "0.60.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.60.0.tgz", + "integrity": "sha512-m/5d3bxQALllCzezYDk/6vajh0tj5OijMMvOZGr+qN1NMXm1dzMNwyJ0gNZW7Fo3YFRyj/jJMxIw+W7d525dlw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.214.0", + "@opentelemetry/semantic-conventions": "^1.33.0", + "@opentelemetry/sql-common": "^0.41.2" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-pg": { + "version": "0.66.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.66.0.tgz", + "integrity": "sha512-KxfLGXBb7k2ueaPJfq2GXBDXBly8P+SpR/4Mj410hhNgmQF3sCqwXvUBQxZQkDAmsdBAoenM+yV1LhtsMRamcA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.214.0", + "@opentelemetry/semantic-conventions": "^1.34.0", + "@opentelemetry/sql-common": "^0.41.2", + "@types/pg": "8.15.6", + "@types/pg-pool": "2.0.7" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-tedious": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.33.0.tgz", + "integrity": "sha512-Q6WQwAD01MMTub31GlejoiFACYNw26J426wyjvU7by7fDIr2nZXNW4vhTGs7i7F0TnXBO3xN688g1tdUgYwJ5w==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.214.0", + "@opentelemetry/semantic-conventions": "^1.33.0", + "@types/tedious": "^4.0.14" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.1.tgz", + "integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.7.1.tgz", + "integrity": "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", + "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/sql-common": { + "version": "0.41.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/sql-common/-/sql-common-0.41.2.tgz", + "integrity": "sha512-4mhWm3Z8z+i508zQJ7r6Xi7y4mmoJpdvH0fZPFRkWrdp5fq7hhZ2HhYokEOLkfqSMgPR4Z9EyB3DBkbKGOqZiQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0" + } + }, + "node_modules/@prisma/instrumentation": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@prisma/instrumentation/-/instrumentation-7.6.0.tgz", + "integrity": "sha512-ZPW2gRiwpPzEfgeZgaekhqXrbW+Y2RJKHVqUmlhZhKzRNCcvR6DykzylDrynpArKKRQtLxoZy36fK7U0p3pdgQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.207.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.8" + } + }, + "node_modules/@prisma/instrumentation/node_modules/@opentelemetry/api-logs": { + "version": "0.207.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.207.0.tgz", + "integrity": "sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@prisma/instrumentation/node_modules/@opentelemetry/instrumentation": { + "version": "0.207.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.207.0.tgz", + "integrity": "sha512-y6eeli9+TLKnznrR8AZlQMSJT7wILpXH+6EYq5Vf/4Ao+huI7EedxQHwRgVUOMLFbe7VFDvHJrX9/f4lcwnJsA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.207.0", + "import-in-the-middle": "^2.0.0", + "require-in-the-middle": "^8.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@prisma/instrumentation/node_modules/import-in-the-middle": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-2.0.6.tgz", + "integrity": "sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw==", + "license": "Apache-2.0", + "dependencies": { + "acorn": "^8.15.0", + "acorn-import-attributes": "^1.9.5", + "cjs-module-lexer": "^2.2.0", + "module-details-from-path": "^1.0.4" + } + }, "node_modules/@react-leaflet/core": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", @@ -912,13 +1744,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/@redocly/openapi-core/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, "node_modules/@redocly/openapi-core/node_modules/minimatch": { "version": "5.1.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", @@ -942,16 +1767,6 @@ "node": ">=4" } }, - "node_modules/@redocly/openapi-core/node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/@redocly/openapi-core/node_modules/uri-js-replace": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", @@ -966,6 +1781,418 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/@rollup/plugin-commonjs": { + "version": "28.0.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.1.tgz", + "integrity": "sha512-+tNWdlWKbpB3WgBN7ijjYkq9X5uhjmcvyjEght4NmH5fAU++zfQzAJ6wumLS+dNcvwEZhKx2Z+skY8m7v0wGSA==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "commondir": "^1.0.1", + "estree-walker": "^2.0.2", + "fdir": "^6.2.0", + "is-reference": "1.2.1", + "magic-string": "^0.30.3", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=16.0.0 || 14 >= 14.17" + }, + "peerDependencies": { + "rollup": "^2.68.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@rushstack/eslint-patch": { "version": "1.16.1", "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.16.1.tgz", @@ -973,6 +2200,445 @@ "dev": true, "license": "MIT" }, + "node_modules/@sentry-internal/browser-utils": { + "version": "10.53.1", + "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.53.1.tgz", + "integrity": "sha512-X4d6y8sBMjmNhcDW4eMBU3ASsNIMz8dqaFkhyIMN/dkYr/yZKnbRZPaVuVUGvHKjnlficPpIH0/HK9KBjrYxPw==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.53.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/feedback": { + "version": "10.53.1", + "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.53.1.tgz", + "integrity": "sha512-vVpTI/aEYN5d9IgZeYJWMqVaN0+iFgidSrYNAsZTh1US5sJUzF/wrl+68KdpmCtFROrN3jiAn1oPSwL5CKvEJA==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.53.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/replay": { + "version": "10.53.1", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.53.1.tgz", + "integrity": "sha512-wZNzTBYkgGUPWMuUQv7L64+OJmoCnz7GQNiTrTFK6EVAjJXFBCSsPp/nhif0bLhbk8+0g4xz633uOhpXuQbFdw==", + "license": "MIT", + "dependencies": { + "@sentry-internal/browser-utils": "10.53.1", + "@sentry/core": "10.53.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/replay-canvas": { + "version": "10.53.1", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.53.1.tgz", + "integrity": "sha512-aueLaf/2prExwA76BGU5/bOXCKWqtt6jQXWA6WJQNrmKpPEtZJB4ypnpsou0McXQCF8tur2Y8U0TEkwQP13yJQ==", + "license": "MIT", + "dependencies": { + "@sentry-internal/replay": "10.53.1", + "@sentry/core": "10.53.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/babel-plugin-component-annotate": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-5.3.0.tgz", + "integrity": "sha512-p4q8gn8wcFqZGP/s2MnJCAAd8fTikaU6A0mM97RDHQgStcrYiaS0Sc5zUNfb1V+UOLPuvdEdL6MwyxfzjYJQTA==", + "license": "MIT", + "engines": { + "node": ">= 18" + } + }, + "node_modules/@sentry/browser": { + "version": "10.53.1", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.53.1.tgz", + "integrity": "sha512-zXF373hzUOGzUOrqd8xb1U3LQi5uYC3mwv+z5OMKUUinQlu30tTWBs7ypy6YTchtix9QlYaHWlayUF8vBZ5UjA==", + "license": "MIT", + "dependencies": { + "@sentry-internal/browser-utils": "10.53.1", + "@sentry-internal/feedback": "10.53.1", + "@sentry-internal/replay": "10.53.1", + "@sentry-internal/replay-canvas": "10.53.1", + "@sentry/core": "10.53.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/bundler-plugin-core": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@sentry/bundler-plugin-core/-/bundler-plugin-core-5.3.0.tgz", + "integrity": "sha512-L5T60sWdAI3qWwdg3Ptwek/0TY59PERrxyqp4XMUkroayQvGd9r5dIW9Q1kSeXX9iJ442nXbFZKAOyCKV4Z13Q==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.18.5", + "@sentry/babel-plugin-component-annotate": "5.3.0", + "@sentry/cli": "^2.58.5", + "dotenv": "^16.3.1", + "find-up": "^5.0.0", + "glob": "^13.0.6", + "magic-string": "~0.30.8" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@sentry/cli": { + "version": "2.58.5", + "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.58.5.tgz", + "integrity": "sha512-tavJ7yGUZV+z3Ct2/ZB6mg339i08sAk6HDkgqmSRuQEu2iLS5sl9HIvuXfM6xjv8fwlgFOSy++WNABNAcGHUbg==", + "hasInstallScript": true, + "license": "FSL-1.1-MIT", + "dependencies": { + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.7", + "progress": "^2.0.3", + "proxy-from-env": "^1.1.0", + "which": "^2.0.2" + }, + "bin": { + "sentry-cli": "bin/sentry-cli" + }, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@sentry/cli-darwin": "2.58.5", + "@sentry/cli-linux-arm": "2.58.5", + "@sentry/cli-linux-arm64": "2.58.5", + "@sentry/cli-linux-i686": "2.58.5", + "@sentry/cli-linux-x64": "2.58.5", + "@sentry/cli-win32-arm64": "2.58.5", + "@sentry/cli-win32-i686": "2.58.5", + "@sentry/cli-win32-x64": "2.58.5" + } + }, + "node_modules/@sentry/cli-darwin": { + "version": "2.58.5", + "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.58.5.tgz", + "integrity": "sha512-lYrNzenZFJftfwSya7gwrHGxtE+Kob/e1sr9lmHMFOd4utDlmq0XFDllmdZAMf21fxcPRI1GL28ejZ3bId01fQ==", + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-arm": { + "version": "2.58.5", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.58.5.tgz", + "integrity": "sha512-KtHweSIomYL4WVDrBrYSYJricKAAzxUgX86kc6OnlikbyOhoK6Fy8Vs6vwd52P6dvWPjgrMpUYjW2M5pYXQDUw==", + "cpu": [ + "arm" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-arm64": { + "version": "2.58.5", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.58.5.tgz", + "integrity": "sha512-/4gywFeBqRB6tR/iGMRAJ3HRqY6Z7Yp4l8ZCbl0TDLAfHNxu7schEw4tSnm2/Hh9eNMiOVy4z58uzAWlZXAYBQ==", + "cpu": [ + "arm64" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-i686": { + "version": "2.58.5", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.58.5.tgz", + "integrity": "sha512-G7261dkmyxqlMdyvyP06b+RTIVzp1gZNgglj5UksxSouSUqRd/46W/2pQeOMPhloDYo9yLtCN2YFb3Mw4aUsWw==", + "cpu": [ + "x86", + "ia32" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-x64": { + "version": "2.58.5", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.58.5.tgz", + "integrity": "sha512-rP04494RSmt86xChkQ+ecBNRYSPbyXc4u0IA7R7N1pSLCyO74e5w5Al+LnAq35cMfVbZgz5Sm0iGLjyiUu4I1g==", + "cpu": [ + "x64" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-arm64": { + "version": "2.58.5", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.58.5.tgz", + "integrity": "sha512-AOJ2nCXlQL1KBaCzv38m3i2VmSHNurUpm7xVKd6yAHX+ZoVBI8VT0EgvwmtJR2TY2N2hNCC7UrgRmdUsQ152bA==", + "cpu": [ + "arm64" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-i686": { + "version": "2.58.5", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.58.5.tgz", + "integrity": "sha512-EsuboLSOnlrN7MMPJ1eFvfMDm+BnzOaSWl8eYhNo8W/BIrmNgpRUdBwnWn9Q2UOjJj5ZopukmsiMYtU/D7ml9g==", + "cpu": [ + "x86", + "ia32" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-x64": { + "version": "2.58.5", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.58.5.tgz", + "integrity": "sha512-IZf+XIMiQwj+5NzqbOQfywlOitmCV424Vtf9c+ep61AaVScUFD1TSrQbOcJJv5xGxhlxNOMNgMeZhdexdzrKZg==", + "cpu": [ + "x64" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/core": { + "version": "10.53.1", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.53.1.tgz", + "integrity": "sha512-XG4ezlkyuAPjBC5+9kXC94rXXuqYTw9NRhfaDHssbTFaGnqBR8vQX2UUgZfY7ucbeelRDGfBu1sywoU+mB04uA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/nextjs": { + "version": "10.53.1", + "resolved": "https://registry.npmjs.org/@sentry/nextjs/-/nextjs-10.53.1.tgz", + "integrity": "sha512-pkwqrpAG//LtW5W1Odud0PLLT+rnjDjodUEbScULHVaZE6/Gt+WGBMZmtzpNM+UwhsN19/4PyO7ocLTx/IFrkQ==", + "license": "MIT", + "dependencies": { + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/semantic-conventions": "^1.40.0", + "@rollup/plugin-commonjs": "28.0.1", + "@sentry-internal/browser-utils": "10.53.1", + "@sentry/bundler-plugin-core": "^5.3.0", + "@sentry/core": "10.53.1", + "@sentry/node": "10.53.1", + "@sentry/opentelemetry": "10.53.1", + "@sentry/react": "10.53.1", + "@sentry/vercel-edge": "10.53.1", + "@sentry/webpack-plugin": "^5.3.0", + "rollup": "^4.60.3", + "stacktrace-parser": "^0.1.11" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "next": "^13.2.0 || ^14.0 || ^15.0.0-rc.0 || ^16.0.0-0" + } + }, + "node_modules/@sentry/node": { + "version": "10.53.1", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.53.1.tgz", + "integrity": "sha512-rxHVil0tJAmz+keFcZCj1LaUdgdkK66E/l0gqh2p1209PNCGoD3lnClFr6vusy1aF3zF8O9JPtuMEJzXOKhs+w==", + "license": "MIT", + "dependencies": { + "@fastify/otel": "0.18.0", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/core": "^2.6.1", + "@opentelemetry/instrumentation": "^0.214.0", + "@opentelemetry/instrumentation-amqplib": "0.61.0", + "@opentelemetry/instrumentation-connect": "0.57.0", + "@opentelemetry/instrumentation-dataloader": "0.31.0", + "@opentelemetry/instrumentation-fs": "0.33.0", + "@opentelemetry/instrumentation-generic-pool": "0.57.0", + "@opentelemetry/instrumentation-graphql": "0.62.0", + "@opentelemetry/instrumentation-hapi": "0.60.0", + "@opentelemetry/instrumentation-http": "0.214.0", + "@opentelemetry/instrumentation-kafkajs": "0.23.0", + "@opentelemetry/instrumentation-knex": "0.58.0", + "@opentelemetry/instrumentation-koa": "0.62.0", + "@opentelemetry/instrumentation-lru-memoizer": "0.58.0", + "@opentelemetry/instrumentation-mongodb": "0.67.0", + "@opentelemetry/instrumentation-mongoose": "0.60.0", + "@opentelemetry/instrumentation-mysql": "0.60.0", + "@opentelemetry/instrumentation-mysql2": "0.60.0", + "@opentelemetry/instrumentation-pg": "0.66.0", + "@opentelemetry/instrumentation-tedious": "0.33.0", + "@opentelemetry/sdk-trace-base": "^2.6.1", + "@opentelemetry/semantic-conventions": "^1.40.0", + "@prisma/instrumentation": "7.6.0", + "@sentry/core": "10.53.1", + "@sentry/node-core": "10.53.1", + "@sentry/opentelemetry": "10.53.1", + "import-in-the-middle": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/node-core": { + "version": "10.53.1", + "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.53.1.tgz", + "integrity": "sha512-iH7SMcM/7jPbN+t7+7ussQOiIqI4BMOGt4VYWlV71/z7k0pY+YPaSvlfVkNXfISiDzFAKv0ecCY3BmsLMu1xDQ==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.53.1", + "@sentry/opentelemetry": "10.53.1", + "import-in-the-middle": "^3.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/core": "^1.30.1 || ^2.1.0", + "@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1", + "@opentelemetry/instrumentation": ">=0.57.1 <1", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", + "@opentelemetry/semantic-conventions": "^1.39.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@opentelemetry/core": { + "optional": true + }, + "@opentelemetry/exporter-trace-otlp-http": { + "optional": true + }, + "@opentelemetry/instrumentation": { + "optional": true + }, + "@opentelemetry/sdk-trace-base": { + "optional": true + }, + "@opentelemetry/semantic-conventions": { + "optional": true + } + } + }, + "node_modules/@sentry/opentelemetry": { + "version": "10.53.1", + "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.53.1.tgz", + "integrity": "sha512-Zok6UXla0mFOjd1YnVb1TZtQNOry9v93fXUqx8jmDaygwWM2BwvP8rBQabLz0/OZXo8+35oge+Vmw+QY5aesnA==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.53.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/core": "^1.30.1 || ^2.1.0", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", + "@opentelemetry/semantic-conventions": "^1.39.0" + } + }, + "node_modules/@sentry/react": { + "version": "10.53.1", + "resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.53.1.tgz", + "integrity": "sha512-lrwNq5T/zW84l60894TpKHPcvFuc1I/Hnohecc0TfYVpIcYYuw2orCHoU4v4wgkFaJUpegVetbgdOphViyLVjA==", + "license": "MIT", + "dependencies": { + "@sentry/browser": "10.53.1", + "@sentry/core": "10.53.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.14.0 || 17.x || 18.x || 19.x" + } + }, + "node_modules/@sentry/vercel-edge": { + "version": "10.53.1", + "resolved": "https://registry.npmjs.org/@sentry/vercel-edge/-/vercel-edge-10.53.1.tgz", + "integrity": "sha512-waIOoLfhi1V3xEBJ1s1hpmvvgvcorYfsfm7fQGye0PgVjcBsZUqz32N5iEwkZ2Gz3n4ZOQYibDUqARJi9tOBcw==", + "license": "MIT", + "dependencies": { + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/resources": "^2.6.1", + "@sentry/core": "10.53.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/webpack-plugin": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@sentry/webpack-plugin/-/webpack-plugin-5.3.0.tgz", + "integrity": "sha512-i3OQUrS0FZlXLgq57RIKDp+vHHzuvYKPCKewAPXULWKMsBXFGhP6veGRQ+6To/pmZkkXjEX5ofVNDy9C3jEPKQ==", + "license": "MIT", + "dependencies": { + "@sentry/bundler-plugin-core": "5.3.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "webpack": ">=5.0.0" + } + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -998,56 +2664,6 @@ "tailwindcss": "4.2.4" } }, - "node_modules/@tailwindcss/node/node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@tailwindcss/node/node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@tailwindcss/node/node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@tailwindcss/node/node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tailwindcss/node/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, "node_modules/@tailwindcss/node/node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1058,27 +2674,6 @@ "node": ">=8" } }, - "node_modules/@tailwindcss/node/node_modules/enhanced-resolve": { - "version": "5.21.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", - "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/@tailwindcss/node/node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, "node_modules/@tailwindcss/node/node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -1350,16 +2945,6 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/@tailwindcss/node/node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, "node_modules/@tailwindcss/node/node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1370,20 +2955,6 @@ "node": ">=0.10.0" } }, - "node_modules/@tailwindcss/node/node_modules/tapable": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", - "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/@tailwindcss/oxide": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz", @@ -1669,11 +3240,41 @@ "dev": true, "optional": true }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, "node_modules/@types/geojson": { @@ -1683,6 +3284,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, "node_modules/@types/leaflet": { "version": "1.9.21", "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", @@ -1703,16 +3310,44 @@ "@types/leaflet": "^1.9" } }, + "node_modules/@types/mysql": { + "version": "2.15.27", + "resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.27.tgz", + "integrity": "sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/node": { "version": "22.19.17", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@types/pg": { + "version": "8.15.6", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.6.tgz", + "integrity": "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@types/pg-pool": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/pg-pool/-/pg-pool-2.0.7.tgz", + "integrity": "sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng==", + "license": "MIT", + "dependencies": { + "@types/pg": "*" + } + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -1733,6 +3368,15 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/tedious": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@types/tedious/-/tedious-4.0.14.tgz", + "integrity": "sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.59.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.0.tgz", @@ -1928,29 +3572,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/eslint-visitor-keys": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", @@ -1964,24 +3585,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", @@ -2008,19 +3611,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -2206,29 +3796,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/parser/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/parser/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/@typescript-eslint/parser/node_modules/eslint-visitor-keys": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", @@ -2242,24 +3809,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@typescript-eslint/parser/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, "node_modules/@typescript-eslint/parser/node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -2276,19 +3825,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@typescript-eslint/parser/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/@typescript-eslint/parser/node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -2332,6 +3868,227 @@ "typescript": ">=4.8.4" } }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "license": "Apache-2.0", + "peer": true + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8" + } + }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -2349,6 +4106,41 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "license": "MIT", + "peer": true, + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ajv/node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -2393,6 +4185,79 @@ "node": ">=6" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.29", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", + "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT", + "peer": true + }, "node_modules/caniuse-lite": { "version": "1.0.30001790", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001790.tgz", @@ -2466,16 +4331,6 @@ "dev": true, "license": "MIT" }, - "node_modules/chalk/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/chalk/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -2496,6 +4351,41 @@ "dev": true, "license": "MIT" }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/cjs-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT", + "peer": true + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2511,13 +4401,6 @@ "node": ">= 8" } }, - "node_modules/cross-spawn/node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, "node_modules/cross-spawn/node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -2551,22 +4434,6 @@ "node": ">=8" } }, - "node_modules/cross-spawn/node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2578,7 +4445,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2596,9 +4462,20 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/echarts": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz", @@ -2629,6 +4506,41 @@ "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", "license": "0BSD" }, + "node_modules/electron-to-chromium": { + "version": "1.5.357", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.357.tgz", + "integrity": "sha512-NHlTIQDK8fmVwHwuIzmXYEJ1Ewq3D9wDNc0cWXxDGysP6Pb21giwGNkxiTifyKy/4SoPuN5l6GLP1W9Sv7zB2g==", + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.21.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.3.tgz", + "integrity": "sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "license": "MIT", + "peer": true + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -4013,16 +5925,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/eslint-import-resolver-node/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/eslint-import-resolver-node/node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -4792,24 +6694,6 @@ "win32" ] }, - "node_modules/eslint-import-resolver-typescript/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, "node_modules/eslint-import-resolver-typescript/node_modules/get-tsconfig": { "version": "4.14.0", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", @@ -4849,19 +6733,6 @@ "url": "https://opencollective.com/napi-postinstall" } }, - "node_modules/eslint-import-resolver-typescript/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/eslint-import-resolver-typescript/node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -6368,16 +8239,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/eslint-plugin-import/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/eslint-plugin-import/node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -9755,13 +11616,6 @@ "node": ">= 0.4" } }, - "node_modules/eslint-plugin-react/node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, "node_modules/eslint-plugin-react/node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -10105,16 +11959,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/eslint-plugin-react/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/eslint-plugin-react/node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -10568,19 +12412,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint-scope/node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, "node_modules/eslint-scope/node_modules/estraverse": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", @@ -10622,19 +12453,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/espree/node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/espree/node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -10668,6 +12486,43 @@ "node": ">=4.0" } }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -10678,12 +12533,56 @@ "node": ">=0.10.0" } }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -10739,7 +12638,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, "license": "MIT", "dependencies": { "locate-path": "^6.0.0", @@ -10756,7 +12654,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, "license": "MIT", "dependencies": { "p-locate": "^5.0.0" @@ -10772,7 +12669,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" @@ -10788,7 +12684,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, "license": "MIT", "dependencies": { "p-limit": "^3.0.2" @@ -10804,7 +12699,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -10814,7 +12708,6 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -10823,6 +12716,52 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/forwarded-parse": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/forwarded-parse/-/forwarded-parse-2.1.2.tgz", + "integrity": "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -10836,6 +12775,56 @@ "node": ">=10.13.0" } }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "license": "BSD-2-Clause", + "peer": true + }, + "node_modules/glob/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -10846,6 +12835,21 @@ "node": ">= 4" } }, + "node_modules/import-in-the-middle": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.0.1.tgz", + "integrity": "sha512-pYkiyXVL2Mf3pozdlDGV6NAObxQx13Ae8knZk1UJRJ6uRW/ZRmTGHlQYtrsSl7ubuE5F8CD1z+s1n4RHNuTtuA==", + "license": "Apache-2.0", + "dependencies": { + "acorn": "^8.15.0", + "acorn-import-attributes": "^1.9.5", + "cjs-module-lexer": "^2.2.0", + "module-details-from-path": "^1.0.4" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -10879,6 +12883,60 @@ "node": ">=0.10.0" } }, + "node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -10886,6 +12944,18 @@ "dev": true, "license": "MIT" }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/leaflet": { "version": "1.9.4", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", @@ -10898,6 +12968,20 @@ "integrity": "sha512-rsQ6saQO5ST5Aj6XRFylr5zvarWgzWnrg46zQ1MEOEIHsppdC/8hnN8qMoFvACsPvTioAuysya/TVtog15tyAQ==", "license": "MIT" }, + "node_modules/loader-runner": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", + "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -10905,6 +12989,41 @@ "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT", + "peer": true + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -10943,6 +13062,21 @@ "dev": true, "license": "MIT" }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/module-details-from-path": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", + "license": "MIT" + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -10950,6 +13084,13 @@ "dev": true, "license": "MIT" }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "license": "MIT", + "peer": true + }, "node_modules/next": { "version": "15.5.15", "resolved": "https://registry.npmjs.org/next/-/next-15.5.15.tgz", @@ -11020,12 +13161,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/next/node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -11063,6 +13198,32 @@ "node": ">=0.10.0" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-releases": { + "version": "2.0.44", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz", + "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==", + "license": "MIT" + }, "node_modules/openapi-typescript": { "version": "7.13.0", "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz", @@ -11194,31 +13355,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parse-json/node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/parse-json/node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/parse-json/node_modules/index-to-position": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", @@ -11232,20 +13368,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parse-json/node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/parse-json/node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, "node_modules/parse-json/node_modules/type-fest": { "version": "4.41.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", @@ -11259,6 +13381,80 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.3.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz", + "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/postcss": { "version": "8.5.10", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", @@ -11307,13 +13503,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/postcss/node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, "node_modules/postcss/node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -11324,6 +13513,60 @@ "node": ">=0.10.0" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/react": { "version": "19.2.5", "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", @@ -11359,12 +13602,137 @@ "react-dom": "^19.0.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-in-the-middle": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", + "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3" + }, + "engines": { + "node": ">=9.3.0 || >=8.10.0 <9.0.0" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/schema-utils/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/schema-utils/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/sharp": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", @@ -11915,6 +14283,39 @@ "integrity": "sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==", "license": "ISC" }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "license": "MIT", + "peer": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/stacktrace-parser": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/stacktrace-parser/-/stacktrace-parser-0.1.11.tgz", + "integrity": "sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.7.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -11944,6 +14345,22 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/tailwindcss": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", @@ -11951,12 +14368,120 @@ "dev": true, "license": "MIT" }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser": { + "version": "5.47.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.47.1.tgz", + "integrity": "sha512-tPbLXTI6ohPASb/1YViL428oEHu6/qv1OxqYnfaonVCFHqx4+wCd95pHrQWsL5X4pl90CTyW9piSAsS2L0VoMw==", + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.0.tgz", + "integrity": "sha512-Eum+5ajkaOhf5KbM26osvv21kLD7BaGqQ1UA4Ami4arYwylmGUQTgHFpHDdmJod1q4QXa66p0to/FBKID+J1vA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/type-fest": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.7.1.tgz", + "integrity": "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=8" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -11975,9 +14500,170 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/watchpack": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", + "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", + "license": "MIT", + "peer": true, + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/webpack": { + "version": "5.106.2", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.106.2.tgz", + "integrity": "sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.16.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.20.0", + "es-module-lexer": "^2.0.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "loader-runner": "^4.3.1", + "mime-db": "^1.54.0", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.17", + "watchpack": "^2.5.1", + "webpack-sources": "^3.3.4" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-sources": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.4.1.tgz", + "integrity": "sha512-eACpxRN02yaawnt+uUNIF7Qje6A9zArxBbcAJjK1PK3S9Ycg5jIuJ8pW4q8EMnwNZCEGltcjkRx1QzOxOkKD8A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + }, "node_modules/yargs-parser": { "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index b6474198..f1c8daac 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,7 @@ "codegen": "openapi-typescript http://localhost:8000/openapi.json -o src/lib/api-types.ts" }, "dependencies": { + "@sentry/nextjs": "^10.53.1", "@tanstack/react-query": "^5.50.0", "echarts": "^6.0.0", "echarts-for-react": "^3.0.6", diff --git a/frontend/sentry.client.config.ts b/frontend/sentry.client.config.ts new file mode 100644 index 00000000..613ea9a4 --- /dev/null +++ b/frontend/sentry.client.config.ts @@ -0,0 +1,14 @@ +import * as Sentry from "@sentry/nextjs"; + +const dsn = process.env.NEXT_PUBLIC_GLITCHTIP_DSN; +if (dsn) { + Sentry.init({ + dsn, + environment: process.env.NEXT_PUBLIC_ENVIRONMENT ?? "development", + tracesSampleRate: 0.05, + // GlitchTip does not support Session Replay — disable explicitly + replaysSessionSampleRate: 0, + replaysOnErrorSampleRate: 0, + integrations: [], + }); +} diff --git a/frontend/sentry.edge.config.ts b/frontend/sentry.edge.config.ts new file mode 100644 index 00000000..c92e6ec7 --- /dev/null +++ b/frontend/sentry.edge.config.ts @@ -0,0 +1,6 @@ +import * as Sentry from "@sentry/nextjs"; + +const dsn = process.env.GLITCHTIP_DSN ?? process.env.NEXT_PUBLIC_GLITCHTIP_DSN; +if (dsn) { + Sentry.init({ dsn, tracesSampleRate: 0.05 }); +} diff --git a/frontend/sentry.server.config.ts b/frontend/sentry.server.config.ts new file mode 100644 index 00000000..2f179e83 --- /dev/null +++ b/frontend/sentry.server.config.ts @@ -0,0 +1,10 @@ +import * as Sentry from "@sentry/nextjs"; + +const dsn = process.env.GLITCHTIP_DSN ?? process.env.NEXT_PUBLIC_GLITCHTIP_DSN; +if (dsn) { + Sentry.init({ + dsn, + environment: process.env.NODE_ENV ?? "development", + tracesSampleRate: 0.05, + }); +} diff --git a/frontend/src/instrumentation.ts b/frontend/src/instrumentation.ts new file mode 100644 index 00000000..9f63313f --- /dev/null +++ b/frontend/src/instrumentation.ts @@ -0,0 +1,11 @@ +export async function register() { + if (process.env.NEXT_RUNTIME === "nodejs") { + await import("../sentry.server.config"); + } + if (process.env.NEXT_RUNTIME === "edge") { + await import("../sentry.edge.config"); + } +} + +export const onRequestError = (await import("@sentry/nextjs")) + .captureRequestError; From fbb76812f478b0d11ff7ff24e0dbb80d4189ba01 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 16 May 2026 18:29:35 +0300 Subject: [PATCH 023/342] fix(recommend): cad_buildings.floors is INTEGER, not TEXT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PROD 500 on /api/v1/analytics/recommend/mix: psycopg.errors.UndefinedFunction: operator does not exist: integer ~ unknown LINE 19: AND ((cb.floors ~ '^[0-9]+$' AND cb.f... _district_cadastre_baseline use regex \ on cb.floors. Schema is INTEGER (verified information_schema), so the regex defence against text values ('1-2', '2-3' etc) is misplaced — simplify to floors >= 3 with NULL safety via OR purpose ILIKE '%многокв%' fallback. --- backend/app/services/analytics_queries.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/backend/app/services/analytics_queries.py b/backend/app/services/analytics_queries.py index e80ca003..0b3855df 100644 --- a/backend/app/services/analytics_queries.py +++ b/backend/app/services/analytics_queries.py @@ -1349,10 +1349,9 @@ def _district_cadastre_baseline(db: Session, *, district_name: str) -> dict[str, WHERE cb.cost_value IS NOT NULL AND cb.area IS NOT NULL AND cb.area >= 100 - -- floors хранится как TEXT (встречаются '1-2', '2-3') — - -- считаем только чистые числа ≥3, либо purpose-fallback. - AND ((cb.floors ~ '^[0-9]+$' AND cb.floors::int >= 3) - OR cb.purpose ILIKE '%многокв%') + -- floors INTEGER (Rosreestr ETL приводит к int); NULL = unknown. + -- Считаем МКД если floors ≥3 или purpose содержит «многокв». + AND (cb.floors >= 3 OR cb.purpose ILIKE '%многокв%') AND (cb.cost_value / NULLIF(cb.area, 0)) BETWEEN 5000 AND 500000 ) From 021b0589b2f7d5e93040fb99069b0356b385d57e Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 16 May 2026 15:54:29 +0000 Subject: [PATCH 024/342] =?UTF-8?q?fix(infra):=20bootstrap=5Fglitchtip=20?= =?UTF-8?q?=E2=80=94=20\gexec=20instead=20of=20DO=20block=20(psql=20var=20?= =?UTF-8?q?trap)=20(#219)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/bootstrap_glitchtip.sh | 33 ++++++++++++++++----------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/scripts/bootstrap_glitchtip.sh b/scripts/bootstrap_glitchtip.sh index 007bb5df..36db58c8 100644 --- a/scripts/bootstrap_glitchtip.sh +++ b/scripts/bootstrap_glitchtip.sh @@ -48,28 +48,27 @@ for i in $(seq 1 30); do done # ── Create glitchtip DB + user (idempotent) ──────────────────────────────── -# Password is passed via psql variable -v to avoid SQL injection from special -# chars (quotes, backslashes) in $GLITCHTIP_DB_PASS. format('%L', :'gt_pass') -# properly quotes the value server-side, so the shell never interpolates it -# into the SQL string. +# psql var :'gt_pass' НЕ подставляется внутри DO $$..$$ — тело dollar-quote +# уходит на сервер as-is. Используем \gexec в psql-стриме client-side. +# +# \gexec builds SQL client-side via quote_literal(:'gt_pass'), then executes it. +# quote_literal escapes apostrophes and backslashes — no SQL injection possible. +# If WHERE NOT EXISTS filters the row, \gexec receives no input → no-op, no error. +# ON_ERROR_STOP=1 ensures \gexec-executed statements also cause psql to exit non-zero. echo "==> Creating glitchtip database and user (idempotent)..." $COMPOSE exec -T postgres \ - psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" \ - -v "gt_pass=$GLITCHTIP_DB_PASS" \ - -v ON_ERROR_STOP=on <<'SQL' -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'glitchtip') THEN - EXECUTE format('CREATE ROLE glitchtip LOGIN PASSWORD %L', :'gt_pass'); - RAISE NOTICE 'Created role glitchtip'; - ELSE - RAISE NOTICE 'Role glitchtip already exists'; - END IF; -END$$; + psql -U "$POSTGRES_USER" -d postgres \ + -v ON_ERROR_STOP=1 \ + -v "gt_pass=$GLITCHTIP_DB_PASS" <<'SQL' +SELECT 'CREATE ROLE glitchtip LOGIN PASSWORD ' || quote_literal(:'gt_pass') || ';' +WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'glitchtip') +\gexec -SELECT 'CREATE DATABASE glitchtip OWNER glitchtip' +SELECT 'CREATE DATABASE glitchtip OWNER glitchtip;' WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'glitchtip') \gexec + +GRANT ALL PRIVILEGES ON DATABASE glitchtip TO glitchtip; SQL # ── Start GlitchTip containers ───────────────────────────────────────────── From ed3c1285288e0e352a45313e2026d28f2eae0692 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 16 May 2026 19:06:22 +0300 Subject: [PATCH 025/342] feat(nspd): TIER 4 opportunity layers + red lines (#94 PR2 of 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NSPDClient: QUARTER_OPPORTUNITY_LAYERS (auction/scheme/free/future/oopt) + QuarterDump.opportunity field - nspd_sync: harvest_quarter accepts include_opportunity, denorm cols has_auction_parcels + opportunity_count in UPSERT - quarter_dump_lookup: - _get_opportunity_parcels (sort by distance, early-exit on count=0) - _get_red_lines (query existing dump.red_lines core path, layer='red_lines') - SQL 89_*: has_auction_parcels + opportunity_count + partial index - Pydantic: OpportunityParcel + RedLine schemas - /analyze: nspd_opportunity_parcels + nspd_red_lines fields - Frontend: NspdOpportunityBlock + NspdRedLinesBlock + LandTab integration - Tests: 28 total (11 PR1 + 17 PR2), all pass Red lines uses existing core harvest path (layer 879243 already in dump.red_lines from PR1+core) — no duplicate harvest, no red_lines_count_v2 column needed. Part of #94 --- backend/app/api/v1/parcels.py | 8 + backend/app/schemas/parcel.py | 39 +++ backend/app/services/scrapers/nspd_client.py | 32 ++- .../site_finder/quarter_dump_lookup.py | 263 +++++++++++++++++- backend/app/workers/tasks/nspd_sync.py | 39 ++- .../services/test_quarter_dump_lookup.py | 198 ++++++++++++- ...89_nspd_quarter_dumps_opportunity_flag.sql | 43 +++ .../src/components/site-finder/LandTab.tsx | 30 +- .../site-finder/NspdOpportunityBlock.tsx | 162 +++++++++++ .../site-finder/NspdRedLinesBlock.tsx | 136 +++++++++ frontend/src/types/nspd.ts | 22 ++ frontend/src/types/site-finder.ts | 3 + 12 files changed, 966 insertions(+), 9 deletions(-) create mode 100644 data/sql/89_nspd_quarter_dumps_opportunity_flag.sql create mode 100644 frontend/src/components/site-finder/NspdOpportunityBlock.tsx create mode 100644 frontend/src/components/site-finder/NspdRedLinesBlock.tsx diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 5dd72961..ba80c31c 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -21,9 +21,11 @@ from app.schemas.parcel import ( CompetitorsRequest, CompetitorsResponse, ConnectionPointsResponse, + OpportunityParcel, ParcelDetail, ParcelSearchRequest, ParcelSearchResponse, + RedLine, RiskZone, ) from app.services.exporters.layout_tz_pdf import render_layout_tz_pdf @@ -2065,11 +2067,17 @@ def analyze_parcel( # nspd_zouit_overlaps: ЗОУИТ пересечения (G3) # nspd_engineering_nearby: инженерные сооружения в 200м (I3) # nspd_risk_zones: TIER 3 risk zones (#94) — подтопление, эрозия, гари, оползни + # nspd_opportunity_parcels: TIER 4 opportunity ЗУ (#94 PR2) + # nspd_red_lines: TIER 4 красные линии застройки (#94 PR2, #54 Generative) # nspd_dump: freshness metadata — available, stale, harvest_triggered "nspd_zoning": nspd_dump_data["nspd_zoning"], "nspd_zouit_overlaps": nspd_dump_data["nspd_zouit_overlaps"], "nspd_engineering_nearby": nspd_dump_data["nspd_engineering_nearby"], "nspd_risk_zones": [RiskZone(**rz) for rz in nspd_dump_data.get("nspd_risk_zones", [])], + "nspd_opportunity_parcels": [ + OpportunityParcel(**op) for op in nspd_dump_data.get("nspd_opportunity_parcels", []) + ], + "nspd_red_lines": [RedLine(**rl) for rl in nspd_dump_data.get("nspd_red_lines", [])], "nspd_dump": nspd_dump_data["nspd_dump"], # #32 G5: gate verdict — can-build-MKD aggregated signal for UI banner "gate_verdict": compute_gate_verdict( diff --git a/backend/app/schemas/parcel.py b/backend/app/schemas/parcel.py index 48990eff..6f92375e 100644 --- a/backend/app/schemas/parcel.py +++ b/backend/app/schemas/parcel.py @@ -80,6 +80,45 @@ class RiskZone(BaseModel): intersection_area_sqm: float | None # площадь пересечения с участком, м² +# ── NSPD Opportunity + Red Lines schemas (issue #94 TIER 4) ────────────────── + + +class OpportunityParcel(BaseModel): + """TIER 4: opportunity ЗУ вблизи участка (auction/scheme/free/future/oopt). + + Поля: + layer: тип opportunity — "auction_parcels" | "scheme_parcels" | + "free_parcels" | "future_parcels" | "oopt" + cad_num: кадастровый номер ЗУ (если доступен в NSPD properties) + distance_m: расстояние от centroid анализируемого участка до ЗУ, м + geom_wkt: WKT геометрии ЗУ в EPSG:4326 + """ + + layer: str + cad_num: str | None + distance_m: float | None + geom_wkt: str | None + + +class RedLine(BaseModel): + """TIER 4: красная линия застройки (NSPD layer 879243). + + Поля: + geom_wkt: WKT геометрии линии в EPSG:4326 + intersection_area_sqm: площадь пересечения с участком, м² (null если только nearby) + distance_m: расстояние от ближайшей точки линии до участка, м + (null если линия пересекает участок) + + UI-семантика: + intersection_area_sqm != null → красная линия ПЕРЕСЕКАЕТ участок (ALERT) + distance_m != null → красная линия рядом (warning) + """ + + geom_wkt: str | None + intersection_area_sqm: float | None # null если только nearby, не intersect + distance_m: float | None # null если intersect + + # ── Parcel search/detail schemas ────────────────────────────────────────────── diff --git a/backend/app/services/scrapers/nspd_client.py b/backend/app/services/scrapers/nspd_client.py index da78fdf0..e2bd760a 100644 --- a/backend/app/services/scrapers/nspd_client.py +++ b/backend/app/services/scrapers/nspd_client.py @@ -199,6 +199,9 @@ class QuarterDump: engineering_structures: list[NSPDFeature] zouit: dict[str, list[NSPDFeature]] # {"okn": [...], "engineering": [...], ...} risks: dict[str, list[NSPDFeature]] # {"flooding": [...], "landslide": [...], ...} + # TIER 4 opportunity layers (issue #94 PR2). + # {"auction_parcels": [...], "scheme_parcels": [...], "free_parcels": [...], ...} + opportunity: dict[str, list[NSPDFeature]] # tuple, не list — frozen dataclass + immutable contents (audit/debug snapshot) layers_fetched: tuple[str, ...] bbox_3857: tuple[float, float, float, float] | None # bbox квартала @@ -215,6 +218,7 @@ class QuarterDump: + len(self.engineering_structures) + sum(len(v) for v in self.zouit.values()) + sum(len(v) for v in self.risks.values()) + + sum(len(v) for v in self.opportunity.values()) ) @@ -489,6 +493,16 @@ class NSPDClient: "clutter": "risk_clutter", "burns": "risk_burns", } + # TIER 4 — Opportunity layers (issue #94 PR2). + # short_name → LAYERS dict key (for get_features_in_bbox lookup). + # Features stored in features_json с layer = "opportunity_". + QUARTER_OPPORTUNITY_LAYERS: dict[str, str] = { # noqa: RUF012 + "auction_parcels": "auction_parcels", # 37299 — аукционные ЗУ + "scheme_parcels": "scheme_parcels", # 37294 — схемы расположения ЗУ + "free_parcels": "free_parcels", # 37298 — свободные ЗУ + "future_parcels": "future_parcels", # 36473 — планируемые ЗУ + "oopt": "protected_areas", # 875845 — ООПТ + } def search_by_quarter( self, @@ -496,6 +510,7 @@ class NSPDClient: *, include_zouit: bool = True, include_risks: bool = False, + include_opportunity: bool = False, ) -> QuarterDump: """Harvest всех NSPD-данных для квартала: 1 vacuum, N layers. @@ -517,13 +532,17 @@ class NSPDClient: include_zouit: Включать TIER 2 ЗОУИТ layers (G3). Default True. include_risks: Включать TIER 3 risk zones. Default False (rate-limit budget; для отдельного D-N risk score можно включить). + include_opportunity: Включать TIER 4 opportunity layers (auction_parcels, + scheme_parcels, free_parcels, future_parcels, oopt). Default False. + +5 HTTP запросов при включении. Returns: QuarterDump с per-layer feature lists. Если NSPD пуст / quarter не найден — `quarter=None`, `bbox_3857=None`, все feature lists пустые (no bulk-fetch без bounds — нет смысла). При этом dict- - поля `zouit` / `risks` всё равно populated с пустыми lists для - каждого включённого short_name (структура контракта стабильна). + поля `zouit` / `risks` / `opportunity` всё равно populated с пустыми + lists для каждого включённого short_name + (структура контракта стабильна). `layers_fetched` в этом случае содержит только `('search',)`. Raises: @@ -532,7 +551,7 @@ class NSPDClient: операция атомарна (failure → exception). Закрывает: foundation для G1 #28 ПЗЗ, G3 #30 ЗОУИТ, P2 #46 neighbors, - E1 #51 parcels backfill, #96 ЕГРН помещения. + E1 #51 parcels backfill, #96 ЕГРН помещения, #94 PR2 opportunity. """ # 1. Quarter geometry через REST search quarter_search = self.search_by_cad(quarter_cad, thematic_id=2) @@ -577,6 +596,12 @@ class NSPDClient: for short_name, layer_key in self.QUARTER_RISK_LAYERS.items(): risks[short_name] = _fetch_layer(f"risk_{short_name}", layer_key) + # 6. Opportunity layers (TIER 4, issue #94 PR2) + opportunity: dict[str, list[NSPDFeature]] = {} + if include_opportunity: + for short_name, layer_key in self.QUARTER_OPPORTUNITY_LAYERS.items(): + opportunity[short_name] = _fetch_layer(f"opportunity_{short_name}", layer_key) + return QuarterDump( quarter_cad=quarter_cad, quarter=quarter_feat, @@ -587,6 +612,7 @@ class NSPDClient: engineering_structures=engineering_structures, zouit=zouit, risks=risks, + opportunity=opportunity, layers_fetched=tuple(layers_fetched), bbox_3857=bbox, fetched_at_utc=_dt.datetime.now(_dt.UTC).isoformat(), diff --git a/backend/app/services/site_finder/quarter_dump_lookup.py b/backend/app/services/site_finder/quarter_dump_lookup.py index d44d9136..56222c18 100644 --- a/backend/app/services/site_finder/quarter_dump_lookup.py +++ b/backend/app/services/site_finder/quarter_dump_lookup.py @@ -39,6 +39,8 @@ EMPTY_DUMP_RESULT: dict[str, Any] = { "nspd_zouit_overlaps": [], "nspd_engineering_nearby": [], "nspd_risk_zones": [], + "nspd_opportunity_parcels": [], + "nspd_red_lines": [], "nspd_dump": { "available": False, "fetched_at_utc": None, @@ -65,6 +67,8 @@ def make_empty_result( "nspd_zouit_overlaps": [], "nspd_engineering_nearby": [], "nspd_risk_zones": [], + "nspd_opportunity_parcels": [], + "nspd_red_lines": [], "nspd_dump": { "available": False, "fetched_at_utc": fetched_at_utc, @@ -128,7 +132,9 @@ def get_quarter_dump_data( territorial_zones_count, zouit_count, engineering_count, - risks_count + risks_count, + COALESCE(opportunity_count, 0) AS opportunity_count, + COALESCE(red_lines_count, 0) AS red_lines_count FROM nspd_quarter_dumps WHERE quarter_cad = :q """ @@ -155,6 +161,8 @@ def get_quarter_dump_data( zouit_count: int = row[5] or 0 engineering_count: int = row[6] or 0 risks_count: int = row[7] or 0 + opportunity_count: int = row[8] or 0 + red_lines_count: int = row[9] or 0 is_stale = (now - fetched_at) > max_age has_error = harvest_error is not None @@ -185,6 +193,8 @@ def get_quarter_dump_data( "nspd_zouit_overlaps": [], "nspd_engineering_nearby": [], "nspd_risk_zones": [], + "nspd_opportunity_parcels": [], + "nspd_red_lines": [], "nspd_dump": dump_meta, } @@ -193,17 +203,23 @@ def get_quarter_dump_data( "zouit_count": zouit_count, "engineering_count": engineering_count, "risks_count": risks_count, + "opportunity_count": opportunity_count, + "red_lines_count": red_lines_count, } nspd_zoning = _get_zoning(db, quarter, parcel_wkt, layer_counts) nspd_zouit = _get_zouit_overlaps(db, quarter, parcel_wkt, layer_counts) nspd_engineering = _get_engineering_nearby(db, quarter, parcel_wkt, layer_counts) nspd_risk_zones = _get_risk_zones(db, quarter, parcel_wkt, layer_counts) + nspd_opportunity = _get_opportunity_parcels(db, quarter, parcel_wkt, layer_counts) + nspd_red_lines = _get_red_lines(db, quarter, parcel_wkt, layer_counts) return { "nspd_zoning": nspd_zoning, "nspd_zouit_overlaps": nspd_zouit, "nspd_engineering_nearby": nspd_engineering, "nspd_risk_zones": nspd_risk_zones, + "nspd_opportunity_parcels": nspd_opportunity, + "nspd_red_lines": nspd_red_lines, "nspd_dump": dump_meta, } @@ -549,6 +565,241 @@ def _get_risk_zones( return [] +# ── Opportunity parcels (issue #94 TIER 4) ─────────────────────────────────── + +# Human-readable type labels for opportunity layer short_names. +_OPPORTUNITY_TYPE_LABELS: dict[str, str] = { + "auction_parcels": "auction_parcels", + "scheme_parcels": "scheme_parcels", + "free_parcels": "free_parcels", + "future_parcels": "future_parcels", + "oopt": "oopt", +} + +# Radius (m) for opportunity parcel proximity search from parcel centroid. +_OPPORTUNITY_RADIUS_M = 500 + + +def _get_opportunity_parcels( + db: Session, + quarter: str, + parcel_wkt: str, + layer_counts: dict[str, int] | None = None, +) -> list[dict[str, Any]]: + """TIER 4: opportunity parcels вблизи участка (auction, scheme, free, future, oopt). + + Возвращает список ближайших opportunity ЗУ с полями: + layer, cad_num, distance_m, geom_wkt. + + layer_counts — денормализованные счётчики. Если opportunity_count == 0 — + пропускаем heavy jsonb_array_elements scan (early-exit pattern как у risks). + """ + if layer_counts is not None and layer_counts.get("opportunity_count", 1) == 0: + return [] + try: + rows = db.execute( + text( + """ + SELECT feat.value->>'layer' AS layer, + feat.value->'properties' AS props, + ST_AsText( + ST_Transform( + ST_SetSRID( + ST_GeomFromGeoJSON(feat.value->>'geometry'), + 3857 + ), + 4326 + ) + ) AS geom_wkt, + ST_Distance( + ST_Transform( + ST_SetSRID( + ST_GeomFromGeoJSON(feat.value->>'geometry'), + 3857 + ), + 4326 + )::geography, + ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography + ) AS distance_m + FROM nspd_quarter_dumps d, + jsonb_array_elements(d.features_json) AS feat(value) + WHERE d.quarter_cad = :q + AND feat.value->>'layer' LIKE 'opportunity_%' + AND (feat.value->'geometry') IS NOT NULL + AND feat.value->>'geometry' != 'null' + AND ST_DWithin( + ST_Transform( + ST_SetSRID( + ST_GeomFromGeoJSON(feat.value->>'geometry'), + 3857 + ), + 4326 + )::geography, + ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography, + :radius_m + ) + ORDER BY distance_m ASC + LIMIT 30 + """ + ), + {"q": quarter, "wkt": parcel_wkt, "radius_m": _OPPORTUNITY_RADIUS_M}, + ).fetchall() + + result: list[dict[str, Any]] = [] + for r in rows: + raw_layer: str = r[0] or "" + props: dict[str, Any] = r[1] if isinstance(r[1], dict) else {} + geom_wkt_val: str | None = r[2] + distance_m = float(r[3]) if r[3] is not None else None + + # Strip "opportunity_" prefix to get short_name → type label + short_name = raw_layer.removeprefix("opportunity_") + layer_type = _OPPORTUNITY_TYPE_LABELS.get(short_name, short_name) + + cad_num = props.get("cad_num") or props.get("cadastral_number") + + result.append( + { + "layer": layer_type, + "cad_num": cad_num, + "distance_m": round(distance_m, 1) if distance_m is not None else None, + "geom_wkt": geom_wkt_val, + } + ) + return result + except Exception as e: + logger.warning("nspd opportunity parcels query failed for quarter=%s: %s", quarter, e) + return [] + + +# Radius (m) for red lines proximity search — beyond intersect check. +_RED_LINES_NEARBY_M = 200 + + +def _get_red_lines( + db: Session, + quarter: str, + parcel_wkt: str, + layer_counts: dict[str, int] | None = None, +) -> list[dict[str, Any]]: + """TIER 4: красные линии застройки (layer 879243). + + Возвращает red lines пересекающие участок ИЛИ ближайшие (до 200м) с полями: + geom_wkt, intersection_area_sqm, distance_m. + + Логика: + - intersecting: intersection_area_sqm > 0, distance_m = None + - nearby only: intersection_area_sqm = None, distance_m = расстояние + + layer_counts — денормализованные счётчики. Если red_lines_count == 0 — + пропускаем heavy jsonb_array_elements scan. + """ + if layer_counts is not None and layer_counts.get("red_lines_count", 1) == 0: + return [] + try: + rows = db.execute( + text( + """ + SELECT ST_AsText( + ST_Transform( + ST_SetSRID( + ST_GeomFromGeoJSON(feat.value->>'geometry'), + 3857 + ), + 4326 + ) + ) AS geom_wkt, + ST_Intersects( + ST_Transform( + ST_SetSRID( + ST_GeomFromGeoJSON(feat.value->>'geometry'), + 3857 + ), + 4326 + ), + ST_GeomFromText(:wkt, 4326) + ) AS does_intersect, + ST_Area( + ST_Intersection( + ST_Transform( + ST_SetSRID( + ST_GeomFromGeoJSON(feat.value->>'geometry'), + 3857 + ), + 4326 + )::geography, + ST_GeomFromText(:wkt, 4326)::geography + ) + ) AS intersection_area_sqm, + ST_Distance( + ST_Transform( + ST_SetSRID( + ST_GeomFromGeoJSON(feat.value->>'geometry'), + 3857 + ), + 4326 + )::geography, + ST_GeomFromText(:wkt, 4326)::geography + ) AS distance_m + FROM nspd_quarter_dumps d, + jsonb_array_elements(d.features_json) AS feat(value) + WHERE d.quarter_cad = :q + AND feat.value->>'layer' = 'red_lines' + AND (feat.value->'geometry') IS NOT NULL + AND feat.value->>'geometry' != 'null' + AND ST_DWithin( + ST_Transform( + ST_SetSRID( + ST_GeomFromGeoJSON(feat.value->>'geometry'), + 3857 + ), + 4326 + )::geography, + ST_GeomFromText(:wkt, 4326)::geography, + :nearby_m + ) + ORDER BY distance_m ASC + LIMIT 50 + """ + ), + {"q": quarter, "wkt": parcel_wkt, "nearby_m": _RED_LINES_NEARBY_M}, + ).fetchall() + + result: list[dict[str, Any]] = [] + for r in rows: + geom_wkt_val: str | None = r[0] + does_intersect: bool = bool(r[1]) if r[1] is not None else False + raw_area: Any = r[2] + raw_dist: Any = r[3] + + intersection_area_sqm: float | None = None + if does_intersect and raw_area is not None: + try: + val = float(raw_area) + intersection_area_sqm = round(val, 1) if not math.isnan(val) else None + except (TypeError, ValueError): + pass + + distance_m: float | None = None + if not does_intersect and raw_dist is not None: + try: + distance_m = round(float(raw_dist), 1) + except (TypeError, ValueError): + pass + + result.append( + { + "geom_wkt": geom_wkt_val, + "intersection_area_sqm": intersection_area_sqm, + "distance_m": distance_m, + } + ) + return result + except Exception as e: + logger.warning("nspd red lines query failed for quarter=%s: %s", quarter, e) + return [] + + # ── Connection-points lookup (issue #115) ──────────────────────────────────── @@ -863,7 +1114,15 @@ def _trigger_harvest(quarter: str) -> bool: try: from app.workers.tasks.nspd_sync import harvest_quarter - harvest_quarter.apply_async(args=[quarter], kwargs={"region_code": 66}) + harvest_quarter.apply_async( + args=[quarter], + kwargs={ + "region_code": 66, + "include_zouit": True, + "include_risks": True, + "include_opportunity": True, + }, + ) logger.info("quarter dump harvest triggered for quarter=%s", quarter) return True except Exception as e: diff --git a/backend/app/workers/tasks/nspd_sync.py b/backend/app/workers/tasks/nspd_sync.py index f4eec507..f87dcefc 100644 --- a/backend/app/workers/tasks/nspd_sync.py +++ b/backend/app/workers/tasks/nspd_sync.py @@ -82,6 +82,12 @@ def _build_features_json(dump: QuarterDump) -> list[dict[str, Any]]: for feat in features: out.append(_feat_to_dict(layer_name, feat)) + # TIER 4 opportunity groups — keys: auction_parcels, scheme_parcels, ... + for short_name, features in dump.opportunity.items(): + layer_name = f"opportunity_{short_name}" + for feat in features: + out.append(_feat_to_dict(layer_name, feat)) + return out @@ -95,6 +101,16 @@ def _build_risks_count(dump: QuarterDump) -> int: return sum(len(v) for v in dump.risks.values()) +def _build_opportunity_count(dump: QuarterDump) -> int: + """Сумма features по всем TIER 4 opportunity слоям (issue #94 PR2).""" + return sum(len(v) for v in dump.opportunity.values()) + + +def _build_has_auction_parcels(dump: QuarterDump) -> bool: + """True если квартал содержит >= 1 feature auction_parcels (layer 37299).""" + return len(dump.opportunity.get("auction_parcels", [])) > 0 + + # ── UPSERT helper ───────────────────────────────────────────────────────────── _UPSERT_SQL = text( @@ -103,6 +119,7 @@ _UPSERT_SQL = text( quarter_cad, quarter_geom, bbox_3857, parcels_count, buildings_count, territorial_zones_count, red_lines_count, engineering_count, zouit_count, risks_count, total_features, + has_auction_parcels, opportunity_count, features_json, layers_fetched, fetched_at_utc, harvest_duration_ms, harvest_error, region_code ) VALUES ( @@ -115,6 +132,7 @@ _UPSERT_SQL = text( END, :parcels_count, :buildings_count, :territorial_zones_count, :red_lines_count, :engineering_count, :zouit_count, :risks_count, :total_features, + :has_auction_parcels, :opportunity_count, CAST(:features_json AS jsonb), CAST(:layers_fetched AS text[]), CAST(:fetched_at_utc AS timestamptz), @@ -133,6 +151,8 @@ _UPSERT_SQL = text( zouit_count = EXCLUDED.zouit_count, risks_count = EXCLUDED.risks_count, total_features = EXCLUDED.total_features, + has_auction_parcels = EXCLUDED.has_auction_parcels, + opportunity_count = EXCLUDED.opportunity_count, features_json = EXCLUDED.features_json, layers_fetched = EXCLUDED.layers_fetched, fetched_at_utc = EXCLUDED.fetched_at_utc, @@ -178,6 +198,8 @@ def _upsert_dump( "engineering_count": len(dump.engineering_structures), "zouit_count": _build_zouit_count(dump), "risks_count": _build_risks_count(dump), + "has_auction_parcels": _build_has_auction_parcels(dump), + "opportunity_count": _build_opportunity_count(dump), "total_features": dump.total_features, "features_json": json.dumps(features_json or [], ensure_ascii=False), "layers_fetched": list(dump.layers_fetched), @@ -202,6 +224,8 @@ def _upsert_dump( "engineering_count": 0, "zouit_count": 0, "risks_count": 0, + "has_auction_parcels": False, + "opportunity_count": 0, "total_features": 0, "features_json": "[]", "layers_fetched": [], @@ -239,20 +263,26 @@ def harvest_quarter( region_code: int = 66, include_zouit: bool = True, include_risks: bool = False, + include_opportunity: bool = False, ) -> dict[str, Any]: """Single-quarter harvest. NSPDClient.search_by_quarter → UPSERT nspd_quarter_dumps. Идемпотентен: повторный вызов обновляет строку (ON CONFLICT DO UPDATE). WAF 403/429 → autoretry с exponential backoff (max 3 попытки). Другие исключения → запись harvest_error в строку, return error dict (не raise). + + Args: + include_opportunity: Фетчить TIER 4 opportunity layers (+5 HTTP запросов). """ t0 = time.monotonic() logger.info( - "harvest_quarter start: cad=%s region=%d include_zouit=%s include_risks=%s", + "harvest_quarter start: cad=%s region=%d include_zouit=%s " + "include_risks=%s include_opportunity=%s", quarter_cad, region_code, include_zouit, include_risks, + include_opportunity, ) client = NSPDClient() @@ -264,6 +294,7 @@ def harvest_quarter( quarter_cad, include_zouit=include_zouit, include_risks=include_risks, + include_opportunity=include_opportunity, ) features_json = _build_features_json(dump) duration_ms = int((time.monotonic() - t0) * 1000) @@ -391,7 +422,11 @@ def harvest_stale_quarters( try: harvest_quarter.apply_async( args=[cad, region_code], - kwargs={"include_zouit": True, "include_risks": True}, + kwargs={ + "include_zouit": True, + "include_risks": True, + "include_opportunity": True, + }, ) enqueued += 1 except Exception as e: diff --git a/backend/tests/services/test_quarter_dump_lookup.py b/backend/tests/services/test_quarter_dump_lookup.py index 244b7a9e..044d1c30 100644 --- a/backend/tests/services/test_quarter_dump_lookup.py +++ b/backend/tests/services/test_quarter_dump_lookup.py @@ -1,9 +1,12 @@ -"""Тесты для quarter_dump_lookup.py — risk zones + generic layer extraction. +"""Тесты для quarter_dump_lookup.py — risk zones + generic layer extraction + TIER 4. Покрывает: - _extract_features_by_layer: filter by prefix - _get_risk_zones / extract through get_quarter_dump_data: intersect, no-intersect, no-risks-in-dump +- _get_opportunity_parcels: auction/scheme/free/future/oopt layers, distance sort, empty +- _get_red_lines: intersecting, nearby-only, empty, early-exit +- EMPTY_DUMP_RESULT / make_empty_result: new keys присутствуют Не требует реального PostgreSQL — мокает db.execute(). """ @@ -15,7 +18,10 @@ from unittest.mock import MagicMock import pytest from app.services.site_finder.quarter_dump_lookup import ( + EMPTY_DUMP_RESULT, _extract_features_by_layer, + _get_opportunity_parcels, + _get_red_lines, _get_risk_zones, derive_quarter_cad, make_empty_result, @@ -183,6 +189,196 @@ def test_make_empty_result_has_risk_zones_key() -> None: assert result["nspd_risk_zones"] == [] +# ── _get_opportunity_parcels ───────────────────────────────────────────────── + + +def test_get_opportunity_parcels_finds_auction() -> None: + """3 features (1 auction + 2 other types) — все возвращаются, auction в нужном layer.""" + rows: list[tuple[Any, ...]] = [ + # (layer, props, geom_wkt, distance_m) + ( + "opportunity_auction_parcels", + {"cad_num": "66:41:0204016:101"}, + "POLYGON((0 0,1 0,1 1,0 0))", + 50.0, + ), + ( + "opportunity_scheme_parcels", + {"cadastral_number": "66:41:0204016:102"}, + "POLYGON((1 1,2 1,2 2,1 1))", + 150.0, + ), + ( + "opportunity_oopt", + {}, + "POLYGON((2 2,3 2,3 3,2 2))", + 300.0, + ), + ] + db = _make_mock_db_with_rows(rows) + + result = _get_opportunity_parcels(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))") + + assert len(result) == 3 + + auction = next(r for r in result if r["layer"] == "auction_parcels") + assert auction["cad_num"] == "66:41:0204016:101" + assert auction["distance_m"] == 50.0 + + scheme = next(r for r in result if r["layer"] == "scheme_parcels") + assert scheme["cad_num"] == "66:41:0204016:102" + + oopt = next(r for r in result if r["layer"] == "oopt") + assert oopt["cad_num"] is None + assert oopt["distance_m"] == 300.0 + + +def test_get_opportunity_parcels_distance_sort() -> None: + """Результаты отсортированы по distance_m ASC (SQL ORDER BY передаётся DB).""" + rows: list[tuple[Any, ...]] = [ + # Порядок: ближайший первым (SQL уже сортирует — тест проверяет что порядок сохраняется) + ( + "opportunity_free_parcels", + {"cad_num": "66:41:0204016:10"}, + "POINT(0 0)", + 10.0, + ), + ( + "opportunity_auction_parcels", + {"cad_num": "66:41:0204016:20"}, + "POINT(1 1)", + 200.0, + ), + ( + "opportunity_future_parcels", + {}, + "POINT(2 2)", + 450.0, + ), + ] + db = _make_mock_db_with_rows(rows) + + result = _get_opportunity_parcels(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))") + + assert len(result) == 3 + # Порядок из DB (mock возвращает в порядке rows) — ближайший первый + assert result[0]["distance_m"] == 10.0 + assert result[1]["distance_m"] == 200.0 + assert result[2]["distance_m"] == 450.0 + + +def test_get_opportunity_parcels_empty() -> None: + """Нет opportunity features — пустой список.""" + db = _make_mock_db_with_rows([]) + result = _get_opportunity_parcels(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))") + assert result == [] + + +def test_get_opportunity_parcels_early_exit() -> None: + """opportunity_count == 0 → early exit, db.execute не вызывается.""" + db = MagicMock() + layer_counts = {"opportunity_count": 0} + result = _get_opportunity_parcels( + db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))", layer_counts + ) + assert result == [] + db.execute.assert_not_called() + + +def test_get_opportunity_parcels_db_exception_returns_empty() -> None: + """DB exception → пустой список (не propagate).""" + db = MagicMock() + db.execute.side_effect = Exception("connection lost") + result = _get_opportunity_parcels(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))") + assert result == [] + + +# ── _get_red_lines ──────────────────────────────────────────────────────────── + + +def test_get_red_lines_intersects() -> None: + """Red line intersecting parcel → intersection_area_sqm filled, distance_m=None.""" + rows: list[tuple[Any, ...]] = [ + # (geom_wkt, does_intersect, intersection_area_sqm, distance_m) + ( + "LINESTRING(0 0, 1 1)", + True, + 500.0, + 0.0, # intersecting → distance_m becomes None in result + ), + ] + db = _make_mock_db_with_rows(rows) + + result = _get_red_lines(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))") + + assert len(result) == 1 + r = result[0] + assert r["geom_wkt"] == "LINESTRING(0 0, 1 1)" + assert r["intersection_area_sqm"] == 500.0 + assert r["distance_m"] is None # null when intersecting + + +def test_get_red_lines_nearby_only() -> None: + """Red line nearby only (не intersect) → distance_m filled, intersection_area_sqm=None.""" + rows: list[tuple[Any, ...]] = [ + # (geom_wkt, does_intersect, intersection_area_sqm, distance_m) + ( + "LINESTRING(10 10, 20 20)", + False, + 0.0, # нет пересечения + 85.5, + ), + ] + db = _make_mock_db_with_rows(rows) + + result = _get_red_lines(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))") + + assert len(result) == 1 + r = result[0] + assert r["intersection_area_sqm"] is None + assert r["distance_m"] == 85.5 + + +def test_get_red_lines_empty() -> None: + """Нет red lines features — пустой список.""" + db = _make_mock_db_with_rows([]) + result = _get_red_lines(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))") + assert result == [] + + +def test_get_red_lines_early_exit() -> None: + """red_lines_count == 0 → early exit, db.execute не вызывается.""" + db = MagicMock() + layer_counts = {"red_lines_count": 0} + result = _get_red_lines(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))", layer_counts) + assert result == [] + db.execute.assert_not_called() + + +# ── EMPTY_DUMP_RESULT / make_empty_result includes new TIER 4 keys ──────────── + + +def test_empty_dump_result_has_opportunity_key() -> None: + """EMPTY_DUMP_RESULT содержит nspd_opportunity_parcels: [].""" + assert "nspd_opportunity_parcels" in EMPTY_DUMP_RESULT + assert EMPTY_DUMP_RESULT["nspd_opportunity_parcels"] == [] + + +def test_empty_dump_result_has_red_lines_key() -> None: + """EMPTY_DUMP_RESULT содержит nspd_red_lines: [].""" + assert "nspd_red_lines" in EMPTY_DUMP_RESULT + assert EMPTY_DUMP_RESULT["nspd_red_lines"] == [] + + +def test_make_empty_result_has_tier4_keys() -> None: + """make_empty_result() возвращает nspd_opportunity_parcels и nspd_red_lines.""" + result = make_empty_result() + assert "nspd_opportunity_parcels" in result + assert result["nspd_opportunity_parcels"] == [] + assert "nspd_red_lines" in result + assert result["nspd_red_lines"] == [] + + # ── derive_quarter_cad (smoke tests) ────────────────────────────────────────── diff --git a/data/sql/89_nspd_quarter_dumps_opportunity_flag.sql b/data/sql/89_nspd_quarter_dumps_opportunity_flag.sql new file mode 100644 index 00000000..cc56e5e8 --- /dev/null +++ b/data/sql/89_nspd_quarter_dumps_opportunity_flag.sql @@ -0,0 +1,43 @@ +-- 89_nspd_quarter_dumps_opportunity_flag.sql +-- Context : TIER 4 opportunity denorm columns for nspd_quarter_dumps. +-- Enables fast map filter "find quarters with auction parcels" without +-- unpacking features_json JSONB on every query. +-- Part of issue #94 PR 2 (TIER 4 opportunity layers). +-- Dependencies: 88_nspd_quarter_dumps.sql (table must exist) +-- Deploy order: after 88_nspd_quarter_dumps.sql +-- Idempotent: yes — ADD COLUMN IF NOT EXISTS + CREATE INDEX IF NOT EXISTS +-- Related: +-- - backend/app/services/scrapers/nspd_client.py :: LAYERS dict (TIER 4) +-- - backend/app/services/site_finder/quarter_dump_lookup.py :: _get_opportunity_parcels +-- - backend/app/workers/tasks/nspd_sync.py :: _build_opportunity_count, _build_has_auction_parcels +-- Note: red_lines (layer 879243) is a TIER 1 core layer — always harvested, +-- counted in red_lines_count (88_*.sql). No separate v2 column needed. + +BEGIN; + +-- Denorm columns for fast filter on map (find quarters with opportunity) +ALTER TABLE nspd_quarter_dumps + ADD COLUMN IF NOT EXISTS has_auction_parcels BOOLEAN DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS opportunity_count INTEGER DEFAULT 0; + +COMMENT ON COLUMN nspd_quarter_dumps.has_auction_parcels IS + 'TRUE если квартал содержит >= 1 feature слоя auction_parcels (NSPD layer 37299). ' + 'Заполняется при harvest (include_opportunity=True). Используется для быстрого ' + 'поиска кварталов с аукционными ЗУ на карте. (#94 PR2)'; + +COMMENT ON COLUMN nspd_quarter_dumps.opportunity_count IS + 'Сумма features TIER 4 opportunity layers: ' + 'auction_parcels (37299) + scheme_parcels (37294) + free_parcels (37298) + ' + 'future_parcels (36473) + protected_areas/oopt (875845). ' + 'Заполняется при harvest (include_opportunity=True). (#94 PR2)'; + +-- Partial index for quick "find quarters with auction parcels" map filter +CREATE INDEX IF NOT EXISTS idx_nspd_quarter_dumps_auction + ON nspd_quarter_dumps (quarter_cad) + WHERE has_auction_parcels = TRUE; + +COMMENT ON INDEX idx_nspd_quarter_dumps_auction IS + 'Partial index: быстрый поиск кварталов с аукционными ЗУ для UI-фильтра карты. ' + 'Малая кардинальность (только TRUE rows) — очень компактный. (#94 PR2)'; + +COMMIT; diff --git a/frontend/src/components/site-finder/LandTab.tsx b/frontend/src/components/site-finder/LandTab.tsx index 601b1ebd..8a9bd016 100644 --- a/frontend/src/components/site-finder/LandTab.tsx +++ b/frontend/src/components/site-finder/LandTab.tsx @@ -11,6 +11,8 @@ import { NspdZoningBlock } from "./NspdZoningBlock"; import { NspdZouitOverlapsBlock } from "./NspdZouitOverlapsBlock"; import { NspdEngineeringNearbyBlock } from "./NspdEngineeringNearbyBlock"; import { NspdRiskZonesBlock } from "./NspdRiskZonesBlock"; +import { NspdOpportunityBlock } from "./NspdOpportunityBlock"; +import { NspdRedLinesBlock } from "./NspdRedLinesBlock"; import { NspdFreshnessBadge } from "./NspdFreshnessBadge"; interface Props { @@ -26,7 +28,11 @@ export function LandTab({ data }: Props) { data.nspd_zoning !== undefined || data.nspd_zouit_overlaps !== undefined || data.nspd_engineering_nearby !== undefined || - data.nspd_risk_zones !== undefined; + data.nspd_risk_zones !== undefined || + (data.nspd_opportunity_parcels !== undefined && + (data.nspd_opportunity_parcels?.length ?? 0) > 0) || + (data.nspd_red_lines !== undefined && + (data.nspd_red_lines?.length ?? 0) > 0); return (
@@ -70,6 +76,28 @@ export function LandTab({ data }: Props) {
)} + {/* Issue #94 PR2 TIER 4 — Opportunity parcels */} + {(data.nspd_opportunity_parcels?.length ?? 0) > 0 && ( +
+ + Возможности рядом (НСПД) + + +
+ )} + + {/* Issue #94 PR2 TIER 4 — Red lines */} + {(data.nspd_red_lines?.length ?? 0) > 0 && ( +
+ + Красные линии застройки (НСПД) + + +
+ )} + {/* Issue #94 TIER 3 — Риск-зоны НСПД */} {data.nspd_risk_zones !== undefined && (
diff --git a/frontend/src/components/site-finder/NspdOpportunityBlock.tsx b/frontend/src/components/site-finder/NspdOpportunityBlock.tsx new file mode 100644 index 00000000..d946a483 --- /dev/null +++ b/frontend/src/components/site-finder/NspdOpportunityBlock.tsx @@ -0,0 +1,162 @@ +"use client"; + +import type { OpportunityParcel } from "@/types/nspd"; + +interface Props { + opportunityParcels: OpportunityParcel[] | null | undefined; +} + +type LayerKey = OpportunityParcel["layer"]; + +const LAYER_CONFIG: Record< + LayerKey, + { label: string; bg: string; badgeBg: string; color: string } +> = { + auction_parcels: { + label: "На аукционе", + bg: "#fff7ed", + badgeBg: "#fed7aa", + color: "#c2410c", + }, + scheme_parcels: { + label: "Схема расположения", + bg: "#eff6ff", + badgeBg: "#bfdbfe", + color: "#1d4ed8", + }, + free_parcels: { + label: "Свободный от прав", + bg: "#f0fdf4", + badgeBg: "#bbf7d0", + color: "#15803d", + }, + future_parcels: { + label: "Планируемый (проект межевания)", + bg: "#f9fafb", + badgeBg: "#e5e7eb", + color: "#374151", + }, + oopt: { + label: "ООПТ", + bg: "#faf5ff", + badgeBg: "#e9d5ff", + color: "#7e22ce", + }, +}; + +function formatDistance(m: number | null): string { + if (m === null) return ""; + if (m < 1000) return `${Math.round(m)} м`; + return `${(m / 1000).toFixed(1)} км`; +} + +function buildNspdViewerUrl(cadNum: string): string { + return `https://nspd.gov.ru/map?cadastralNumber=${encodeURIComponent(cadNum)}`; +} + +export function NspdOpportunityBlock({ opportunityParcels }: Props) { + const parcels = opportunityParcels ?? []; + + if (parcels.length === 0) { + return null; + } + + // Group by layer type + const grouped = parcels.reduce>( + (acc, p) => { + const key = p.layer as LayerKey; + if (!acc[key]) acc[key] = []; + acc[key].push(p); + return acc; + }, + {} as Record, + ); + + // Stable display order: auction first (highest priority) + const layerOrder: LayerKey[] = [ + "auction_parcels", + "free_parcels", + "scheme_parcels", + "future_parcels", + "oopt", + ]; + + return ( +
+ {layerOrder.map((layerKey) => { + const items = grouped[layerKey]; + if (!items || items.length === 0) return null; + const cfg = LAYER_CONFIG[layerKey]; + + return ( +
+
1 ? 8 : 0, + }} + > + + {cfg.label} + + + {items.length} уч. + +
+ + {items.map((p, idx) => ( +
+ {p.cad_num ? ( + + {p.cad_num} + + ) : ( + + )} + {p.distance_m !== null && ( + + {formatDistance(p.distance_m)} + + )} +
+ ))} +
+ ); + })} +
+ ); +} diff --git a/frontend/src/components/site-finder/NspdRedLinesBlock.tsx b/frontend/src/components/site-finder/NspdRedLinesBlock.tsx new file mode 100644 index 00000000..e6628c6e --- /dev/null +++ b/frontend/src/components/site-finder/NspdRedLinesBlock.tsx @@ -0,0 +1,136 @@ +"use client"; + +import type { RedLine } from "@/types/nspd"; + +interface Props { + redLines: RedLine[] | null | undefined; +} + +function formatArea(sqm: number | null): string | null { + if (sqm === null) return null; + if (sqm >= 10000) return `${(sqm / 10000).toFixed(2)} га`; + return `${Math.round(sqm).toLocaleString("ru-RU")} м²`; +} + +function formatDistance(m: number | null): string | null { + if (m === null) return null; + if (m < 1000) return `${Math.round(m)} м`; + return `${(m / 1000).toFixed(1)} км`; +} + +export function NspdRedLinesBlock({ redLines }: Props) { + const lines = redLines ?? []; + + if (lines.length === 0) { + return null; + } + + const intersecting = lines.filter((l) => l.intersection_area_sqm !== null); + const nearbyOnly = lines.filter((l) => l.intersection_area_sqm === null); + const hasIntersect = intersecting.length > 0; + + return ( +
+ {/* Alert banner when red lines intersect the parcel */} + {hasIntersect && ( +
+ + ПЕРЕСЕЧЕНИЕ + +
+
+ Красные линии пересекают участок — отступы могут быть критичны +
+
+ {intersecting.map((l, idx) => { + const areaStr = formatArea(l.intersection_area_sqm); + return ( +
+ Линия {idx + 1} + {areaStr ? `: пересечение ${areaStr}` : ""} +
+ ); + })} +
+
+
+ )} + + {/* Nearby-only red lines */} + {nearbyOnly.length > 0 && ( +
+ + РЯДОМ + +
+
+ {nearbyOnly.length} кр. лини{nearbyOnly.length === 1 ? "я" : "и"}{" "} + вблизи участка +
+
+ {nearbyOnly.map((l, idx) => { + const distStr = formatDistance(l.distance_m); + return ( +
+ Линия {idx + 1} + {distStr ? `: ${distStr} от границы` : ""} +
+ ); + })} +
+
+
+ )} + + {/* Summary line */} +
+ Итого: {lines.length} красн. лини{lines.length === 1 ? "я" : "и"} + {hasIntersect + ? `, из них ${intersecting.length} пересека${intersecting.length === 1 ? "ет" : "ют"} участок` + : ""} +
+
+ ); +} diff --git a/frontend/src/types/nspd.ts b/frontend/src/types/nspd.ts index 024f5313..7f8cdae6 100644 --- a/frontend/src/types/nspd.ts +++ b/frontend/src/types/nspd.ts @@ -41,6 +41,28 @@ export interface RiskZone { intersection_area_sqm: number | null; } +// Opportunity parcels (issue #94 TIER 4) + +export interface OpportunityParcel { + layer: + | "auction_parcels" + | "scheme_parcels" + | "free_parcels" + | "future_parcels" + | "oopt"; + cad_num: string | null; + distance_m: number | null; + geom_wkt: string | null; +} + +// Red lines (issue #94 TIER 4 + #54 Generative foundation) + +export interface RedLine { + geom_wkt: string | null; + intersection_area_sqm: number | null; // null if only nearby, not intersecting + distance_m: number | null; // null if intersecting +} + // Connection-points endpoint shapes (/api/v1/parcels/{cad}/connection-points) export interface EngineeringStructure { diff --git a/frontend/src/types/site-finder.ts b/frontend/src/types/site-finder.ts index 3d66b542..cb4f469f 100644 --- a/frontend/src/types/site-finder.ts +++ b/frontend/src/types/site-finder.ts @@ -384,6 +384,9 @@ export interface ParcelAnalysis { nspd_zouit_overlaps?: import("./nspd").NspdZouitOverlap[]; nspd_engineering_nearby?: import("./nspd").NspdEngineeringNearby[]; nspd_risk_zones?: import("./nspd").RiskZone[]; + // Issue #94 PR2 — TIER 4 opportunity layers + red lines + nspd_opportunity_parcels?: import("./nspd").OpportunityParcel[]; + nspd_red_lines?: import("./nspd").RedLine[]; nspd_dump?: import("./nspd").NspdDumpMeta | null; } From fdb54834e5b1fe76c4ecd719902a70238c9e5c6f Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 16 May 2026 19:33:14 +0300 Subject: [PATCH 026/342] =?UTF-8?q?fix(nspd):=20rename=20migration=2089?= =?UTF-8?q?=E2=86=9298,=20fix=20red=20lines=20ST=5FArea=E2=86=92ST=5FLengt?= =?UTF-8?q?h=20(#220)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - data/sql/98_*: rename from 89 to avoid collision with existing 89_drop_dead_brin - _get_red_lines: ST_Length(ST_Intersection(planar)::geography) instead of ST_Area(ST_Intersection(::geography, ::geography)) — fixes PostGIS 3.4 tolerance error and returns correct non-zero length for LINESTRING intersections - Rename intersection_area_sqm → intersection_length_m across schema, TS types, frontend component, and tests; add test_get_red_lines_db_exception_returns_empty Addresses review-bot feedback on PR #220. --- backend/app/schemas/parcel.py | 7 ++-- .../site_finder/quarter_dump_lookup.py | 32 +++++++++++-------- .../services/test_quarter_dump_lookup.py | 22 +++++++++---- ...8_nspd_quarter_dumps_opportunity_flag.sql} | 2 +- .../site-finder/NspdRedLinesBlock.tsx | 16 +++++----- frontend/src/types/nspd.ts | 2 +- 6 files changed, 47 insertions(+), 34 deletions(-) rename data/sql/{89_nspd_quarter_dumps_opportunity_flag.sql => 98_nspd_quarter_dumps_opportunity_flag.sql} (98%) diff --git a/backend/app/schemas/parcel.py b/backend/app/schemas/parcel.py index 6f92375e..8d210704 100644 --- a/backend/app/schemas/parcel.py +++ b/backend/app/schemas/parcel.py @@ -105,17 +105,18 @@ class RedLine(BaseModel): Поля: geom_wkt: WKT геометрии линии в EPSG:4326 - intersection_area_sqm: площадь пересечения с участком, м² (null если только nearby) + intersection_length_m: длина пересечения линии с участком, м + (null если только nearby, не intersect) distance_m: расстояние от ближайшей точки линии до участка, м (null если линия пересекает участок) UI-семантика: - intersection_area_sqm != null → красная линия ПЕРЕСЕКАЕТ участок (ALERT) + intersection_length_m != null → красная линия ПЕРЕСЕКАЕТ участок (ALERT) distance_m != null → красная линия рядом (warning) """ geom_wkt: str | None - intersection_area_sqm: float | None # null если только nearby, не intersect + intersection_length_m: float | None # null если только nearby, не intersect distance_m: float | None # null если intersect diff --git a/backend/app/services/site_finder/quarter_dump_lookup.py b/backend/app/services/site_finder/quarter_dump_lookup.py index 56222c18..b6b17e06 100644 --- a/backend/app/services/site_finder/quarter_dump_lookup.py +++ b/backend/app/services/site_finder/quarter_dump_lookup.py @@ -685,11 +685,15 @@ def _get_red_lines( """TIER 4: красные линии застройки (layer 879243). Возвращает red lines пересекающие участок ИЛИ ближайшие (до 200м) с полями: - geom_wkt, intersection_area_sqm, distance_m. + geom_wkt, intersection_length_m, distance_m. Логика: - - intersecting: intersection_area_sqm > 0, distance_m = None - - nearby only: intersection_area_sqm = None, distance_m = расстояние + - intersecting: intersection_length_m >= 0, distance_m = None + - nearby only: intersection_length_m = None, distance_m = расстояние + + ST_Intersection выполняется в planar EPSG:4326, затем результат кастуется + в ::geography для ST_Length — это избегает PostGIS 3.4 tolerance bug + (ST_Intersection geography × geography на LINESTRING бросает transform error). layer_counts — денормализованные счётчики. Если red_lines_count == 0 — пропускаем heavy jsonb_array_elements scan. @@ -719,7 +723,7 @@ def _get_red_lines( ), ST_GeomFromText(:wkt, 4326) ) AS does_intersect, - ST_Area( + ST_Length( ST_Intersection( ST_Transform( ST_SetSRID( @@ -727,10 +731,10 @@ def _get_red_lines( 3857 ), 4326 - )::geography, - ST_GeomFromText(:wkt, 4326)::geography - ) - ) AS intersection_area_sqm, + ), + ST_GeomFromText(:wkt, 4326) + )::geography + ) AS intersection_length_m, ST_Distance( ST_Transform( ST_SetSRID( @@ -769,14 +773,14 @@ def _get_red_lines( for r in rows: geom_wkt_val: str | None = r[0] does_intersect: bool = bool(r[1]) if r[1] is not None else False - raw_area: Any = r[2] + raw_length: Any = r[2] raw_dist: Any = r[3] - intersection_area_sqm: float | None = None - if does_intersect and raw_area is not None: + intersection_length_m: float | None = None + if does_intersect and raw_length is not None: try: - val = float(raw_area) - intersection_area_sqm = round(val, 1) if not math.isnan(val) else None + val = float(raw_length) + intersection_length_m = round(val, 1) if not math.isnan(val) else None except (TypeError, ValueError): pass @@ -790,7 +794,7 @@ def _get_red_lines( result.append( { "geom_wkt": geom_wkt_val, - "intersection_area_sqm": intersection_area_sqm, + "intersection_length_m": intersection_length_m, "distance_m": distance_m, } ) diff --git a/backend/tests/services/test_quarter_dump_lookup.py b/backend/tests/services/test_quarter_dump_lookup.py index 044d1c30..3b23a011 100644 --- a/backend/tests/services/test_quarter_dump_lookup.py +++ b/backend/tests/services/test_quarter_dump_lookup.py @@ -297,13 +297,13 @@ def test_get_opportunity_parcels_db_exception_returns_empty() -> None: def test_get_red_lines_intersects() -> None: - """Red line intersecting parcel → intersection_area_sqm filled, distance_m=None.""" + """Red line intersecting parcel → intersection_length_m filled, distance_m=None.""" rows: list[tuple[Any, ...]] = [ - # (geom_wkt, does_intersect, intersection_area_sqm, distance_m) + # (geom_wkt, does_intersect, intersection_length_m, distance_m) ( "LINESTRING(0 0, 1 1)", True, - 500.0, + 45.3, # длина пересечения в метрах 0.0, # intersecting → distance_m becomes None in result ), ] @@ -314,14 +314,14 @@ def test_get_red_lines_intersects() -> None: assert len(result) == 1 r = result[0] assert r["geom_wkt"] == "LINESTRING(0 0, 1 1)" - assert r["intersection_area_sqm"] == 500.0 + assert r["intersection_length_m"] == 45.3 assert r["distance_m"] is None # null when intersecting def test_get_red_lines_nearby_only() -> None: - """Red line nearby only (не intersect) → distance_m filled, intersection_area_sqm=None.""" + """Red line nearby only (не intersect) → distance_m filled, intersection_length_m=None.""" rows: list[tuple[Any, ...]] = [ - # (geom_wkt, does_intersect, intersection_area_sqm, distance_m) + # (geom_wkt, does_intersect, intersection_length_m, distance_m) ( "LINESTRING(10 10, 20 20)", False, @@ -335,10 +335,18 @@ def test_get_red_lines_nearby_only() -> None: assert len(result) == 1 r = result[0] - assert r["intersection_area_sqm"] is None + assert r["intersection_length_m"] is None assert r["distance_m"] == 85.5 +def test_get_red_lines_db_exception_returns_empty() -> None: + """DB exception → пустой список (не propagate).""" + db = MagicMock() + db.execute.side_effect = Exception("DB connection lost") + result = _get_red_lines(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))") + assert result == [] + + def test_get_red_lines_empty() -> None: """Нет red lines features — пустой список.""" db = _make_mock_db_with_rows([]) diff --git a/data/sql/89_nspd_quarter_dumps_opportunity_flag.sql b/data/sql/98_nspd_quarter_dumps_opportunity_flag.sql similarity index 98% rename from data/sql/89_nspd_quarter_dumps_opportunity_flag.sql rename to data/sql/98_nspd_quarter_dumps_opportunity_flag.sql index cc56e5e8..5c972a54 100644 --- a/data/sql/89_nspd_quarter_dumps_opportunity_flag.sql +++ b/data/sql/98_nspd_quarter_dumps_opportunity_flag.sql @@ -1,4 +1,4 @@ --- 89_nspd_quarter_dumps_opportunity_flag.sql +-- 98_nspd_quarter_dumps_opportunity_flag.sql -- Context : TIER 4 opportunity denorm columns for nspd_quarter_dumps. -- Enables fast map filter "find quarters with auction parcels" without -- unpacking features_json JSONB on every query. diff --git a/frontend/src/components/site-finder/NspdRedLinesBlock.tsx b/frontend/src/components/site-finder/NspdRedLinesBlock.tsx index e6628c6e..651bab9b 100644 --- a/frontend/src/components/site-finder/NspdRedLinesBlock.tsx +++ b/frontend/src/components/site-finder/NspdRedLinesBlock.tsx @@ -6,10 +6,10 @@ interface Props { redLines: RedLine[] | null | undefined; } -function formatArea(sqm: number | null): string | null { - if (sqm === null) return null; - if (sqm >= 10000) return `${(sqm / 10000).toFixed(2)} га`; - return `${Math.round(sqm).toLocaleString("ru-RU")} м²`; +function formatLength(m: number | null): string | null { + if (m === null) return null; + if (m >= 1000) return `${(m / 1000).toFixed(2)} км`; + return `${Math.round(m)} м`; } function formatDistance(m: number | null): string | null { @@ -25,8 +25,8 @@ export function NspdRedLinesBlock({ redLines }: Props) { return null; } - const intersecting = lines.filter((l) => l.intersection_area_sqm !== null); - const nearbyOnly = lines.filter((l) => l.intersection_area_sqm === null); + const intersecting = lines.filter((l) => l.intersection_length_m !== null); + const nearbyOnly = lines.filter((l) => l.intersection_length_m === null); const hasIntersect = intersecting.length > 0; return ( @@ -64,11 +64,11 @@ export function NspdRedLinesBlock({ redLines }: Props) {
{intersecting.map((l, idx) => { - const areaStr = formatArea(l.intersection_area_sqm); + const lenStr = formatLength(l.intersection_length_m); return (
Линия {idx + 1} - {areaStr ? `: пересечение ${areaStr}` : ""} + {lenStr ? `: длина пересечения ${lenStr}` : ""}
); })} diff --git a/frontend/src/types/nspd.ts b/frontend/src/types/nspd.ts index 7f8cdae6..7b49b3ad 100644 --- a/frontend/src/types/nspd.ts +++ b/frontend/src/types/nspd.ts @@ -59,7 +59,7 @@ export interface OpportunityParcel { export interface RedLine { geom_wkt: string | null; - intersection_area_sqm: number | null; // null if only nearby, not intersecting + intersection_length_m: number | null; // null if only nearby, not intersecting distance_m: number | null; // null if intersecting } From 82931897dd68c95750ad4ea242a44f5b86bbc2cb Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 16 May 2026 19:51:54 +0300 Subject: [PATCH 027/342] fix(infra): restore git.gendsgn.ru block in Caddyfile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Block был добавлен вручную при Forgejo migration (2026-05-16) но не закоммичен в repo. Первый Caddyfile-touching deploy (#205 GlitchTip) сделал git reset --hard origin/main на VPS → блок исчез → TLS handshake internal error 80 на git.gendsgn.ru (prod down ~16:30). Routes git.gendsgn.ru → forgejo:3000 (контейнер из forgejo-migration/ docker-compose.yml, shared gendesign_default network, Forgejo default HTTP port). VPS уже починен hotfix-ом руками; этот PR делает fix permanent — переживёт любой git reset --hard от deploy.yml. --- Caddyfile | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/Caddyfile b/Caddyfile index 4b43b2ed..aab784f6 100644 --- a/Caddyfile +++ b/Caddyfile @@ -56,6 +56,22 @@ errors.gendsgn.ru { } } +# Forgejo — self-hosted git (migration 2026-05-16). +# DNS: A-record git.gendsgn.ru → IP VPS. +# Forgejo container из forgejo-migration/docker-compose.yml на shared +# gendesign_default network. HTTP port 3000 (default Forgejo). +# Был добавлен вручную при migration, потерян при первом auto-deploy после +# изменения Caddyfile (deploy.yml делает git reset --hard). См. fix issue. +git.gendsgn.ru { + encode zstd gzip + + reverse_proxy forgejo:3000 + + log { + output file /var/log/caddy/git.gendsgn.ru.log + } +} + # Plain HTTP by IP — kept for ssh-tunnel / debugging. # Caddy issues no TLS here (no hostname). :80 { From db1ff5b1fc6259200c96bfe4cd61a93016365666 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 16 May 2026 20:20:03 +0300 Subject: [PATCH 028/342] feat(nspd): denorm nspd_parcels + nspd_buildings tables (#94 PR3 of 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - data/sql/99_nspd_entities_denorm.sql: 2 tables (cad_num PK) + 6 indexes (quarter_cad, GIST geom, partial WHERE purpose ILIKE '%многокв%') - nspd_denorm.py: denorm_parcel_feature / denorm_building_feature / denorm_dump - SAVEPOINT per row via with db.begin_nested() - ST_Transform(ST_SetSRID(ST_GeomFromGeoJSON(:geom), 3857), 4326) - json.dumps(geom, ensure_ascii=False) for psycopg bind - All CAST(:x AS type) — no :x::type - nspd_sync.py: inline denorm after _upsert_dump in harvest_quarter (non-fatal) - nspd_denorm_backfill.py: Celery one-shot task (per-quarter commit) - admin_etl.py: POST /api/v1/admin/etl/nspd-denorm-backfill (AdminTokenAuth) - 19 tests: coerce helpers, denorm parcel/building, aggregate counts Foundation for downstream features (МКД neighbors lookup, parcel attrs). Part of #94 --- backend/app/api/v1/admin_etl.py | 26 ++ backend/app/services/scrapers/nspd_denorm.py | 290 ++++++++++++++++ .../app/workers/tasks/nspd_denorm_backfill.py | 96 ++++++ backend/app/workers/tasks/nspd_sync.py | 27 ++ backend/tests/services/test_nspd_denorm.py | 322 ++++++++++++++++++ data/sql/99_nspd_entities_denorm.sql | 171 ++++++++++ 6 files changed, 932 insertions(+) create mode 100644 backend/app/services/scrapers/nspd_denorm.py create mode 100644 backend/app/workers/tasks/nspd_denorm_backfill.py create mode 100644 backend/tests/services/test_nspd_denorm.py create mode 100644 data/sql/99_nspd_entities_denorm.sql diff --git a/backend/app/api/v1/admin_etl.py b/backend/app/api/v1/admin_etl.py index 96b2d51a..047cb68e 100644 --- a/backend/app/api/v1/admin_etl.py +++ b/backend/app/api/v1/admin_etl.py @@ -4,6 +4,9 @@ POST /api/v1/admin/etl/objective-backfill Запустить fuzzy-match backfill objective_complex_mapping. Опциональный REFRESH mv_layout_velocity после успешного backfill. +POST /api/v1/admin/etl/nspd-denorm-backfill + Запустить backfill nspd_parcels/nspd_buildings из всех nspd_quarter_dumps. + Header: X-Admin-Token: """ @@ -68,3 +71,26 @@ def run_objective_backfill( result["mv_rows_after_refresh"] = mv_rows return result + + +@router.post("/nspd-denorm-backfill") +def run_nspd_denorm_backfill( + _: AdminTokenAuth, + limit: Annotated[ + int | None, + Query(description="Максимум кварталов для обработки (None = все)"), + ] = None, +) -> dict[str, object]: + """Запустить Celery backfill: denormalize nspd_quarter_dumps → nspd_parcels/nspd_buildings. + + Задача идемпотентна (ON CONFLICT DO UPDATE). Безопасно запускать повторно. + Возвращает Celery task_id — статус через /flower или celery inspect. + + Args: + limit: если задан — обработать только первые N кварталов ORDER BY quarter_cad. + """ + from app.workers.tasks.nspd_denorm_backfill import backfill_all_dumps + + task = backfill_all_dumps.apply_async(kwargs={"limit": limit}) + logger.info("nspd-denorm-backfill enqueued: task_id=%s limit=%s", task.id, limit) + return {"task_id": task.id, "status": "enqueued", "limit": limit} diff --git a/backend/app/services/scrapers/nspd_denorm.py b/backend/app/services/scrapers/nspd_denorm.py new file mode 100644 index 00000000..de3ee973 --- /dev/null +++ b/backend/app/services/scrapers/nspd_denorm.py @@ -0,0 +1,290 @@ +"""Denormalize nspd_quarter_dumps.features_json → nspd_parcels / nspd_buildings. + +Используется: + - Inline в harvest_quarter task после UPSERT dump'а + - Backfill task для existing dumps (backfill_all_dumps) + +Property mapping (NSPD WMS GetFeatureInfo responses): + Parcels (layer="parcels", layer id 36048): + cad_num — кадастровый номер ЗУ + permitted_use — ВРИ + land_category — категория земель + cost_value — кадастровая стоимость, руб. + area — площадь, м² + address — адрес + + Buildings (layer="buildings", layer id 36049): + cad_num — кадастровый номер ОКС + purpose — назначение ("Многоквартирный дом" / "Нежилое здание" / ...) + floors_above_ground — надземных этажей + floors_underground — подземных этажей + year_built — год постройки + cost_value — кадастровая стоимость, руб. + build_record_area — площадь по ГКН, м² + address — адрес + +Геометрия в features_json хранится в EPSG:3857 (как возвращает NSPD WMS) — +трансформация 3857→4326 делается в SQL через ST_Transform. +""" + +from __future__ import annotations + +import datetime +import json +import logging +from typing import Any + +from sqlalchemy import text +from sqlalchemy.orm import Session + +logger = logging.getLogger(__name__) + +# ── Type coercions ───────────────────────────────────────────────────────────── + + +def _coerce_int(v: Any) -> int | None: + """NSPD properties могут быть str / int / None.""" + if v is None: + return None + try: + return int(v) + except (ValueError, TypeError): + return None + + +def _coerce_numeric(v: Any) -> float | None: + """NSPD properties могут быть str с запятой (европейский формат) или None.""" + if v is None: + return None + try: + return float(str(v).replace(",", ".").strip()) + except (ValueError, TypeError): + return None + + +# ── Parcel UPSERT ────────────────────────────────────────────────────────────── + +_PARCEL_UPSERT_SQL = text( + """ + INSERT INTO nspd_parcels ( + cad_num, quarter_cad, permitted_use, land_category, + cost_value, cost_per_m2, area_sqm, address, geom, snapshot_date + ) VALUES ( + CAST(:cad_num AS text), + CAST(:quarter_cad AS text), + CAST(:permitted_use AS text), + CAST(:land_category AS text), + CAST(:cost_value AS numeric), + CAST(:cost_per_m2 AS numeric), + CAST(:area_sqm AS numeric), + CAST(:address AS text), + CASE WHEN :geom_json IS NULL THEN NULL + ELSE ST_Transform(ST_SetSRID(ST_GeomFromGeoJSON(:geom_json), 3857), 4326) + END, + CAST(:snapshot_date AS date) + ) + ON CONFLICT (cad_num) DO UPDATE SET + quarter_cad = EXCLUDED.quarter_cad, + permitted_use = EXCLUDED.permitted_use, + land_category = EXCLUDED.land_category, + cost_value = EXCLUDED.cost_value, + cost_per_m2 = EXCLUDED.cost_per_m2, + area_sqm = EXCLUDED.area_sqm, + address = EXCLUDED.address, + geom = EXCLUDED.geom, + snapshot_date = EXCLUDED.snapshot_date, + updated_at = NOW() + """ +) + + +def denorm_parcel_feature( + db: Session, + *, + feature: dict[str, Any], + quarter_cad: str, + snapshot_date: str, +) -> bool: + """UPSERT one parcel feature → nspd_parcels. + + Returns True если строка вставлена/обновлена, False при пропуске (нет cad_num). + """ + props = feature.get("properties") or {} + cad_num = props.get("cad_num") or props.get("cadastral_number") + if not cad_num: + return False + + area = _coerce_numeric(props.get("area")) + cost_value = _coerce_numeric(props.get("cost_value")) + cost_per_m2: float | None = None + if cost_value is not None and area is not None and area > 0: + cost_per_m2 = cost_value / area + + geom = feature.get("geometry") + geom_json: str | None = json.dumps(geom, ensure_ascii=False) if geom else None + + try: + with db.begin_nested(): + db.execute( + _PARCEL_UPSERT_SQL, + { + "cad_num": cad_num, + "quarter_cad": quarter_cad, + "permitted_use": props.get("permitted_use"), + "land_category": props.get("land_category"), + "cost_value": cost_value, + "cost_per_m2": cost_per_m2, + "area_sqm": area, + "address": props.get("address"), + "geom_json": geom_json, + "snapshot_date": snapshot_date, + }, + ) + return True + except Exception as e: + logger.warning("denorm parcel %s failed: %s", cad_num, e) + return False + + +# ── Building UPSERT ──────────────────────────────────────────────────────────── + +_BUILDING_UPSERT_SQL = text( + """ + INSERT INTO nspd_buildings ( + cad_num, quarter_cad, purpose, floors, floors_underground, + year_built, cost_value, build_record_area, address, geom, snapshot_date + ) VALUES ( + CAST(:cad_num AS text), + CAST(:quarter_cad AS text), + CAST(:purpose AS text), + CAST(:floors AS integer), + CAST(:floors_underground AS integer), + CAST(:year_built AS integer), + CAST(:cost_value AS numeric), + CAST(:build_record_area AS numeric), + CAST(:address AS text), + CASE WHEN :geom_json IS NULL THEN NULL + ELSE ST_Transform(ST_SetSRID(ST_GeomFromGeoJSON(:geom_json), 3857), 4326) + END, + CAST(:snapshot_date AS date) + ) + ON CONFLICT (cad_num) DO UPDATE SET + quarter_cad = EXCLUDED.quarter_cad, + purpose = EXCLUDED.purpose, + floors = EXCLUDED.floors, + floors_underground = EXCLUDED.floors_underground, + year_built = EXCLUDED.year_built, + cost_value = EXCLUDED.cost_value, + build_record_area = EXCLUDED.build_record_area, + address = EXCLUDED.address, + geom = EXCLUDED.geom, + snapshot_date = EXCLUDED.snapshot_date, + updated_at = NOW() + """ +) + + +def denorm_building_feature( + db: Session, + *, + feature: dict[str, Any], + quarter_cad: str, + snapshot_date: str, +) -> bool: + """UPSERT one building feature → nspd_buildings. + + Returns True если строка вставлена/обновлена, False при пропуске (нет cad_num). + """ + props = feature.get("properties") or {} + cad_num = props.get("cad_num") or props.get("cadastral_number") + if not cad_num: + return False + + geom = feature.get("geometry") + geom_json: str | None = json.dumps(geom, ensure_ascii=False) if geom else None + + try: + with db.begin_nested(): + db.execute( + _BUILDING_UPSERT_SQL, + { + "cad_num": cad_num, + "quarter_cad": quarter_cad, + "purpose": props.get("purpose"), + "floors": _coerce_int(props.get("floors_above_ground")), + "floors_underground": _coerce_int(props.get("floors_underground")), + "year_built": _coerce_int(props.get("year_built")), + "cost_value": _coerce_numeric(props.get("cost_value")), + "build_record_area": _coerce_numeric(props.get("build_record_area")), + "address": props.get("address"), + "geom_json": geom_json, + "snapshot_date": snapshot_date, + }, + ) + return True + except Exception as e: + logger.warning("denorm building %s failed: %s", cad_num, e) + return False + + +# ── Batch helper ─────────────────────────────────────────────────────────────── + + +def denorm_dump( + db: Session, + *, + quarter_cad: str, + features: list[dict[str, Any]], +) -> dict[str, int]: + """Denorm all features из одного quarter dump → nspd_parcels / nspd_buildings. + + Итерирует features_json плоский список. Записи с layer="parcels" идут в + nspd_parcels, layer="buildings" → nspd_buildings. Остальные layers пропускаются. + + Каждая строка UPSERT'ится в отдельном SAVEPOINT (begin_nested) — ошибка + одной строки не откатывает весь batch. + + Args: + db: SQLAlchemy Session. Caller отвечает за commit/close после вызова. + quarter_cad: 3-сегментный кадастровый квартал. + features: плоский list из features_json JSONB (уже декодированный Python list). + + Returns: + dict {"parcels": N, "buildings": M, "errors": K} — количество обработанных строк. + """ + snapshot_date = datetime.date.today().isoformat() + parcels_n = 0 + buildings_n = 0 + errors_n = 0 + + for feat in features: + layer = feat.get("layer", "") + try: + if layer == "parcels": + if denorm_parcel_feature( + db, feature=feat, quarter_cad=quarter_cad, snapshot_date=snapshot_date + ): + parcels_n += 1 + else: + errors_n += 1 + elif layer == "buildings": + if denorm_building_feature( + db, feature=feat, quarter_cad=quarter_cad, snapshot_date=snapshot_date + ): + buildings_n += 1 + else: + errors_n += 1 + # Остальные layers (territorial_zones, zouit_*, risk_*, ...) — пропускаем + except Exception as e: + logger.warning("denorm feature (layer=%s quarter=%s) failed: %s", layer, quarter_cad, e) + errors_n += 1 + + db.commit() + logger.info( + "denorm_dump quarter=%s parcels=%d buildings=%d errors=%d", + quarter_cad, + parcels_n, + buildings_n, + errors_n, + ) + return {"parcels": parcels_n, "buildings": buildings_n, "errors": errors_n} diff --git a/backend/app/workers/tasks/nspd_denorm_backfill.py b/backend/app/workers/tasks/nspd_denorm_backfill.py new file mode 100644 index 00000000..c0788328 --- /dev/null +++ b/backend/app/workers/tasks/nspd_denorm_backfill.py @@ -0,0 +1,96 @@ +"""One-shot backfill: denormalize all existing nspd_quarter_dumps → nspd_parcels/buildings. + +Запускается вручную через admin endpoint POST /api/v1/admin/etl/nspd-denorm-backfill +или через Celery CLI: + celery -A app.workers.celery_app call nspd_denorm.backfill_all_dumps + +Идемпотентен: повторный запуск обновляет строки через ON CONFLICT DO UPDATE. +""" + +from __future__ import annotations + +import logging + +from celery import shared_task +from sqlalchemy import text + +from app.core.db import SessionLocal +from app.services.scrapers.nspd_denorm import denorm_dump + +logger = logging.getLogger(__name__) + + +@shared_task( + name="nspd_denorm.backfill_all_dumps", + bind=True, + soft_time_limit=3600, # 1 час — много кварталов × ~10ms per parcel + max_retries=0, # One-shot, не retry +) +def backfill_all_dumps(self: object, *, limit: int | None = None) -> dict[str, int]: # type: ignore[misc] + """Backfill all existing nspd_quarter_dumps → nspd_parcels / nspd_buildings. + + Читает все строки nspd_quarter_dumps (или limit строк), и для каждой вызывает + denorm_dump. Возвращает агрегированные счётчики по всем кварталам. + + Args: + limit: если задан — обработать только первые N кварталов (для тестирования). + + Returns: + dict { + "parcels": суммарно вставлено/обновлено parcel строк, + "buildings": суммарно вставлено/обновлено building строк, + "errors": суммарно пропусков (нет cad_num, DB ошибки), + "quarters_processed": количество обработанных кварталов, + } + """ + totals: dict[str, int] = { + "parcels": 0, + "buildings": 0, + "errors": 0, + "quarters_processed": 0, + } + + db = SessionLocal() + try: + sql = ( + "SELECT quarter_cad, features_json " + "FROM nspd_quarter_dumps " + "WHERE total_features > 0 " + "ORDER BY quarter_cad" + ) + if limit is not None: + # Используем параметр через text() чтобы избежать SQL-injection. + rows = db.execute( + text(sql + " LIMIT CAST(:lim AS integer)"), {"lim": int(limit)} + ).fetchall() + else: + rows = db.execute(text(sql)).fetchall() + + logger.info("backfill_all_dumps: starting, quarters=%d limit=%s", len(rows), limit) + + for row in rows: + quarter_cad: str = row[0] + features_json = row[1] + # features_json — уже list[dict] через psycopg v3 JSON decoding. + features: list[dict] = features_json if isinstance(features_json, list) else [] + + try: + counts = denorm_dump(db, quarter_cad=quarter_cad, features=features) + totals["parcels"] += counts["parcels"] + totals["buildings"] += counts["buildings"] + totals["errors"] += counts["errors"] + totals["quarters_processed"] += 1 + except Exception as e: + logger.warning("backfill_all_dumps: quarter=%s failed: %s", quarter_cad, e) + totals["errors"] += 1 + # Продолжаем с остальными кварталами + try: + db.rollback() + except Exception: + pass + + logger.info("backfill_all_dumps: done totals=%s", totals) + finally: + db.close() + + return totals diff --git a/backend/app/workers/tasks/nspd_sync.py b/backend/app/workers/tasks/nspd_sync.py index f87dcefc..2743a630 100644 --- a/backend/app/workers/tasks/nspd_sync.py +++ b/backend/app/workers/tasks/nspd_sync.py @@ -33,6 +33,7 @@ from app.services.scrapers.nspd_client import ( NspdLiteWafError, QuarterDump, ) +from app.services.scrapers.nspd_denorm import denorm_dump from app.workers.celery_app import celery_app logger = logging.getLogger(__name__) @@ -301,6 +302,32 @@ def harvest_quarter( _upsert_dump(quarter_cad, region_code, dump, features_json, duration_ms, None) + # Inline denorm: разложить parcels/buildings из dump в nspd_parcels/nspd_buildings. + # Ошибка denorm не должна фейлить весь harvest — только warning. + try: + denorm_db = SessionLocal() + try: + denorm_counts = denorm_dump( + denorm_db, + quarter_cad=quarter_cad, + features=features_json or [], + ) + logger.info( + "harvest_quarter denorm: cad=%s parcels=%d buildings=%d errors=%d", + quarter_cad, + denorm_counts["parcels"], + denorm_counts["buildings"], + denorm_counts["errors"], + ) + finally: + denorm_db.close() + except Exception as denorm_exc: + logger.warning( + "harvest_quarter denorm failed (non-fatal): cad=%s error=%s", + quarter_cad, + denorm_exc, + ) + logger.info( "harvest_quarter done: cad=%s region=%d duration=%dms total=%d", quarter_cad, diff --git a/backend/tests/services/test_nspd_denorm.py b/backend/tests/services/test_nspd_denorm.py new file mode 100644 index 00000000..3420a761 --- /dev/null +++ b/backend/tests/services/test_nspd_denorm.py @@ -0,0 +1,322 @@ +"""Тесты для nspd_denorm.py — coerce helpers + denorm_parcel/building/dump. + +Не требует реального PostgreSQL — мокает db.execute() и db.begin_nested(). +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from app.services.scrapers.nspd_denorm import ( + _coerce_int, + _coerce_numeric, + denorm_building_feature, + denorm_dump, + denorm_parcel_feature, +) + +# ── _coerce_int ──────────────────────────────────────────────────────────────── + + +def test_coerce_int_str_number() -> None: + assert _coerce_int("5") == 5 + + +def test_coerce_int_int_passthrough() -> None: + assert _coerce_int(17) == 17 + + +def test_coerce_int_none_returns_none() -> None: + assert _coerce_int(None) is None + + +def test_coerce_int_invalid_returns_none() -> None: + assert _coerce_int("не_число") is None + + +def test_coerce_int_float_truncates() -> None: + assert _coerce_int(3.9) == 3 + + +# ── _coerce_numeric ──────────────────────────────────────────────────────────── + + +def test_coerce_numeric_float_passthrough() -> None: + assert _coerce_numeric(12.5) == 12.5 + + +def test_coerce_numeric_str_dot() -> None: + assert _coerce_numeric("1234567.89") == pytest.approx(1234567.89) + + +def test_coerce_numeric_comma_decimal() -> None: + """Европейский формат с запятой → корректный float.""" + assert _coerce_numeric("1234567,89") == pytest.approx(1234567.89) + + +def test_coerce_numeric_none_returns_none() -> None: + assert _coerce_numeric(None) is None + + +def test_coerce_numeric_invalid_returns_none() -> None: + assert _coerce_numeric("N/A") is None + + +# ── Helpers для моков ────────────────────────────────────────────────────────── + + +def _make_mock_session() -> MagicMock: + """Мок Session: begin_nested() — context manager, execute() → MagicMock.""" + db = MagicMock() + # begin_nested() должен быть context manager + cm = MagicMock() + cm.__enter__ = MagicMock(return_value=None) + cm.__exit__ = MagicMock(return_value=False) + db.begin_nested.return_value = cm + return db + + +def _parcel_feature( + cad_num: str = "66:41:0204016:10", + area: Any = "500.0", + cost_value: Any = "1500000", + **extra_props: Any, +) -> dict[str, Any]: + props: dict[str, Any] = { + "cad_num": cad_num, + "area": area, + "cost_value": cost_value, + "permitted_use": "Для ИЖС", + "land_category": "Земли населённых пунктов", + "address": "г. Екатеринбург", + **extra_props, + } + return { + "layer": "parcels", + "feature_id": "123", + "geometry": {"type": "Polygon", "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 0]]]}, + "properties": props, + } + + +def _building_feature( + cad_num: str = "66:41:0204016:10:1", + purpose: str = "Многоквартирный дом", + **extra_props: Any, +) -> dict[str, Any]: + props: dict[str, Any] = { + "cad_num": cad_num, + "purpose": purpose, + "floors_above_ground": 9, + "floors_underground": 1, + "year_built": 1985, + "cost_value": "50000000", + "build_record_area": "3200.0", + "address": "ул. Ленина 1", + **extra_props, + } + return { + "layer": "buildings", + "feature_id": "456", + "geometry": {"type": "Polygon", "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 0]]]}, + "properties": props, + } + + +# ── denorm_parcel_feature ────────────────────────────────────────────────────── + + +def test_denorm_parcel_feature_inserts() -> None: + """Полный feature с cad_num → вызывает db.execute с правильными параметрами.""" + db = _make_mock_session() + + result = denorm_parcel_feature( + db, + feature=_parcel_feature(), + quarter_cad="66:41:0204016", + snapshot_date="2026-05-16", + ) + + assert result is True + db.begin_nested.assert_called_once() + db.execute.assert_called_once() + # Проверяем что параметры содержат нужные ключи + call_kwargs = db.execute.call_args[0][1] # positional dict + assert call_kwargs["cad_num"] == "66:41:0204016:10" + assert call_kwargs["quarter_cad"] == "66:41:0204016" + assert call_kwargs["permitted_use"] == "Для ИЖС" + assert call_kwargs["land_category"] == "Земли населённых пунктов" + assert call_kwargs["area_sqm"] == 500.0 + assert call_kwargs["cost_value"] == 1500000.0 + assert call_kwargs["cost_per_m2"] == pytest.approx(3000.0) + assert call_kwargs["snapshot_date"] == "2026-05-16" + + +def test_denorm_parcel_no_cad_num_skipped() -> None: + """Feature без cad_num → возвращает False, db не вызывается.""" + db = _make_mock_session() + feature: dict[str, Any] = { + "layer": "parcels", + "feature_id": None, + "geometry": None, + "properties": {"area": "100"}, + } + result = denorm_parcel_feature( + db, feature=feature, quarter_cad="66:41:0204016", snapshot_date="2026-05-16" + ) + assert result is False + db.execute.assert_not_called() + + +def test_denorm_parcel_zero_area_cost_per_m2_none() -> None: + """area=0 → cost_per_m2 = None (нет деления на ноль).""" + db = _make_mock_session() + result = denorm_parcel_feature( + db, + feature=_parcel_feature(area="0", cost_value="1000"), + quarter_cad="66:41:0204016", + snapshot_date="2026-05-16", + ) + assert result is True + call_kwargs = db.execute.call_args[0][1] + assert call_kwargs["cost_per_m2"] is None + + +def test_denorm_parcel_null_geometry() -> None: + """feature без geometry → geom_json=None, INSERT всё равно вызывается.""" + db = _make_mock_session() + feature = _parcel_feature() + feature["geometry"] = None + + result = denorm_parcel_feature( + db, feature=feature, quarter_cad="66:41:0204016", snapshot_date="2026-05-16" + ) + assert result is True + call_kwargs = db.execute.call_args[0][1] + assert call_kwargs["geom_json"] is None + + +def test_denorm_parcel_db_exception_returns_false() -> None: + """DB execute raises → возвращает False (не propagate).""" + db = _make_mock_session() + db.execute.side_effect = Exception("DB constraint violation") + + result = denorm_parcel_feature( + db, + feature=_parcel_feature(), + quarter_cad="66:41:0204016", + snapshot_date="2026-05-16", + ) + assert result is False + + +# ── denorm_building_feature ──────────────────────────────────────────────────── + + +def test_denorm_building_feature_inserts() -> None: + """Полный building feature → INSERT с правильными параметрами.""" + db = _make_mock_session() + + result = denorm_building_feature( + db, + feature=_building_feature(), + quarter_cad="66:41:0204016", + snapshot_date="2026-05-16", + ) + + assert result is True + db.execute.assert_called_once() + call_kwargs = db.execute.call_args[0][1] + assert call_kwargs["cad_num"] == "66:41:0204016:10:1" + assert call_kwargs["purpose"] == "Многоквартирный дом" + assert call_kwargs["floors"] == 9 + assert call_kwargs["floors_underground"] == 1 + assert call_kwargs["year_built"] == 1985 + assert call_kwargs["cost_value"] == 50000000.0 + assert call_kwargs["build_record_area"] == 3200.0 + + +def test_denorm_building_no_cad_num_skipped() -> None: + """Building feature без cad_num → False.""" + db = _make_mock_session() + feature: dict[str, Any] = { + "layer": "buildings", + "feature_id": None, + "geometry": None, + "properties": {"purpose": "Нежилое здание"}, + } + result = denorm_building_feature( + db, feature=feature, quarter_cad="66:41:0204016", snapshot_date="2026-05-16" + ) + assert result is False + db.execute.assert_not_called() + + +def test_denorm_building_str_floors_coerced() -> None: + """floors_above_ground строкой → корректно парсится в int.""" + db = _make_mock_session() + result = denorm_building_feature( + db, + feature=_building_feature(floors_above_ground="12"), + quarter_cad="66:41:0204016", + snapshot_date="2026-05-16", + ) + assert result is True + call_kwargs = db.execute.call_args[0][1] + assert call_kwargs["floors"] == 12 + + +# ── denorm_dump ──────────────────────────────────────────────────────────────── + + +def test_denorm_dump_aggregates() -> None: + """3 parcels + 2 buildings + 1 unknown layer → правильные счётчики.""" + db = _make_mock_session() + + features: list[dict[str, Any]] = [ + _parcel_feature("66:41:0101001:1"), + _parcel_feature("66:41:0101001:2"), + _parcel_feature("66:41:0101001:3"), + _building_feature("66:41:0101001:1:1"), + _building_feature("66:41:0101001:1:2"), + { + "layer": "territorial_zones", + "feature_id": "tz-1", + "geometry": None, + "properties": {"type_zone": "Ж-1"}, + }, + ] + + counts = denorm_dump(db, quarter_cad="66:41:0101001", features=features) + + assert counts["parcels"] == 3 + assert counts["buildings"] == 2 + # territorial_zones не считается как ошибка — просто пропускается + assert counts["errors"] == 0 + db.commit.assert_called_once() + + +def test_denorm_dump_empty_features() -> None: + """Пустой список features → нули, commit всё равно вызывается.""" + db = _make_mock_session() + counts = denorm_dump(db, quarter_cad="66:41:0101001", features=[]) + + assert counts == {"parcels": 0, "buildings": 0, "errors": 0} + db.commit.assert_called_once() + + +def test_denorm_dump_no_cad_num_counted_as_error() -> None: + """Parcel без cad_num → denorm_parcel_feature returns False → errors += 1.""" + db = _make_mock_session() + feature: dict[str, Any] = { + "layer": "parcels", + "feature_id": None, + "geometry": None, + "properties": {}, + } + counts = denorm_dump(db, quarter_cad="66:41:0101001", features=[feature]) + assert counts["parcels"] == 0 + assert counts["errors"] == 1 diff --git a/data/sql/99_nspd_entities_denorm.sql b/data/sql/99_nspd_entities_denorm.sql new file mode 100644 index 00000000..c65a6f2f --- /dev/null +++ b/data/sql/99_nspd_entities_denorm.sql @@ -0,0 +1,171 @@ +-- 99_nspd_entities_denorm.sql +-- Context : Denormalized entity tables для NSPD parcel и building features. +-- nspd_quarter_dumps.features_json хранит все feature'ы в JSONB; эти +-- таблицы дают быстрый lookup по cad_num без json-unpacking на query time. +-- Источник: layer="parcels" (NSPD layer 36048) и layer="buildings" (36049) +-- из features_json. Геометрия трансформируется 3857→4326 при UPSERT. +-- +-- Dependencies: 88_nspd_quarter_dumps.sql (logical; таблица должна существовать до backfill) +-- Deploy order: после 98_nspd_quarter_dumps_opportunity_flag.sql +-- Idempotent: yes — IF NOT EXISTS на всех объектах +-- Related: +-- - backend/app/services/scrapers/nspd_denorm.py :: denorm_parcel_feature / denorm_building_feature +-- - backend/app/workers/tasks/nspd_denorm_backfill.py :: backfill_all_dumps +-- - backend/app/workers/tasks/nspd_sync.py :: harvest_quarter (inline denorm) +-- Issue: #94 PR3 + +BEGIN; + +-- ── nspd_parcels ────────────────────────────────────────────────────────────── +-- Denormalized parcel features из NSPD quarter dumps (layer="parcels", id 36048). +-- Каждая строка = один земельный участок из любого квартала. +-- PK cad_num: один участок может пересекать несколько кварталов, берём +-- последний harvest (ON CONFLICT DO UPDATE). + +CREATE TABLE IF NOT EXISTS nspd_parcels ( + -- Кадастровый номер участка (4-сегментный, e.g. '66:41:0204016:10'). + -- Первичный ключ — natural, стабильный идентификатор в ЕГРН. + cad_num TEXT PRIMARY KEY, + + -- Квартал из которого была взята последняя версия этого участка. + quarter_cad TEXT NOT NULL, + + -- ВРИ (вид разрешённого использования) — properties.permitted_use из NSPD. + -- Nullable: НСПД не всегда возвращает ВРИ для старых записей. + permitted_use TEXT, + + -- Категория земель (properties.land_category). + -- Nullable: может отсутствовать в ответе. + land_category TEXT, + + -- Кадастровая стоимость ЗУ (properties.cost_value), руб. + cost_value NUMERIC, + + -- Кадастровая стоимость за м² = cost_value / area_sqm. + -- Вычисляется при UPSERT; NULL если area_sqm = 0 или cost_value = NULL. + cost_per_m2 NUMERIC, + + -- Площадь участка (properties.area), м². + area_sqm NUMERIC, + + -- Адрес участка (properties.address). + address TEXT, + + -- Геометрия участка в WGS84 (трансформируется из EPSG:3857 при UPSERT). + -- Geometry не Polygon т.к. НСПД иногда возвращает MultiPolygon или Point. + geom GEOMETRY(Geometry, 4326), + + -- Дата snapshot'а (день harvest'а). + snapshot_date DATE NOT NULL DEFAULT CURRENT_DATE, + + -- Timestamp последнего UPSERT. + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +COMMENT ON TABLE nspd_parcels IS + 'Denormalized parcel features из NSPD quarter dumps (layer=parcels, id 36048). ' + 'PK = cad_num (последний harvest wins). Заполняется Celery task backfill_all_dumps ' + 'или inline в harvest_quarter. Источник: features_json JSONB колонка nspd_quarter_dumps. ' + 'Геометрия трансформирована EPSG:3857→4326. (#94 PR3)'; + +COMMENT ON COLUMN nspd_parcels.cad_num IS + 'Кадастровый номер участка (4-сегментный). Natural PK из НСПД.'; + +COMMENT ON COLUMN nspd_parcels.quarter_cad IS + '3-сегментный квартал последнего harvest'а. Используется для debug/audit.'; + +COMMENT ON COLUMN nspd_parcels.cost_per_m2 IS + 'Кадастровая стоимость за м² (cost_value / area_sqm). ' + 'NULL если area_sqm = 0 или cost_value отсутствует. Вычисляется в Python при UPSERT.'; + +-- Индекс для lookup по кварталу (join с nspd_quarter_dumps). +CREATE INDEX IF NOT EXISTS idx_nspd_parcels_quarter_cad + ON nspd_parcels (quarter_cad); + +-- GIST индекс на геометрию для spatial queries (соседние участки, ST_DWithin). +CREATE INDEX IF NOT EXISTS idx_nspd_parcels_geom_gist + ON nspd_parcels USING GIST (geom); + +-- Partial B-tree на cost_per_m2 для ценовых аналитических запросов. +CREATE INDEX IF NOT EXISTS idx_nspd_parcels_cost_per_m2 + ON nspd_parcels (cost_per_m2) + WHERE cost_per_m2 IS NOT NULL; + + +-- ── nspd_buildings ──────────────────────────────────────────────────────────── +-- Denormalized building features из NSPD quarter dumps (layer="buildings", id 36049). +-- Каждая строка = одно здание/сооружение ЕГРН. + +CREATE TABLE IF NOT EXISTS nspd_buildings ( + -- Кадастровый номер здания (5-сегментный, e.g. '66:41:0204016:10:1'). + cad_num TEXT PRIMARY KEY, + + -- Квартал последнего harvest'а. + quarter_cad TEXT NOT NULL, + + -- Назначение здания (properties.purpose). + -- Ключевые значения: 'Многоквартирный дом', 'Нежилое здание', etc. + purpose TEXT, + + -- Количество надземных этажей (properties.floors_above_ground). + floors INTEGER, + + -- Количество подземных этажей (properties.floors_underground). + floors_underground INTEGER, + + -- Год постройки (properties.year_built). + year_built INTEGER, + + -- Кадастровая стоимость здания (properties.cost_value), руб. + cost_value NUMERIC, + + -- Площадь по данным ГКН (properties.build_record_area), м². + build_record_area NUMERIC, + + -- Адрес здания (properties.address). + address TEXT, + + -- Геометрия здания в WGS84 (трансформируется из EPSG:3857 при UPSERT). + geom GEOMETRY(Geometry, 4326), + + -- Дата snapshot'а. + snapshot_date DATE NOT NULL DEFAULT CURRENT_DATE, + + -- Timestamp последнего UPSERT. + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +COMMENT ON TABLE nspd_buildings IS + 'Denormalized building features из NSPD quarter dumps (layer=buildings, id 36049). ' + 'PK = cad_num (последний harvest wins). Заполняется Celery task backfill_all_dumps ' + 'или inline в harvest_quarter. Ключевой use-case: МКД-соседи для P2-scoring. ' + 'Геометрия трансформирована EPSG:3857→4326. (#94 PR3)'; + +COMMENT ON COLUMN nspd_buildings.purpose IS + 'Назначение здания (properties.purpose). Для МКД = "Многоквартирный дом". ' + 'Используется в partial index idx_nspd_buildings_mkd для быстрой фильтрации.'; + +COMMENT ON COLUMN nspd_buildings.floors IS + 'Количество надземных этажей (properties.floors_above_ground из НСПД). ' + 'Nullable: старые ОКС в ЕГРН не всегда имеют этажность.'; + +-- Индекс для lookup по кварталу. +CREATE INDEX IF NOT EXISTS idx_nspd_buildings_quarter_cad + ON nspd_buildings (quarter_cad); + +-- GIST индекс на геометрию (МКД-соседи через ST_DWithin). +CREATE INDEX IF NOT EXISTS idx_nspd_buildings_geom_gist + ON nspd_buildings USING GIST (geom); + +-- Partial index для МКД (основной use-case P2-scoring). +-- ILIKE не работает в partial index WHERE clause — используем точный строковый prefixmatch. +-- Здания с purpose IS NULL или другим purpose — в idx_nspd_buildings_geom_gist. +CREATE INDEX IF NOT EXISTS idx_nspd_buildings_mkd + ON nspd_buildings (quarter_cad) + WHERE purpose = 'Многоквартирный дом'; + +COMMENT ON INDEX idx_nspd_buildings_mkd IS + 'Partial index для быстрого lookup МКД по кварталу (P2-scoring neighbors). ' + 'WHERE purpose = exact строка из НСПД. (#94 PR3)'; + +COMMIT; From 1ec0a720fbefaa20fef7a7cf67ced83eaa5fa442 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 16 May 2026 19:30:06 +0300 Subject: [PATCH 029/342] =?UTF-8?q?fix(infra):=20GlitchTip=20EMAIL=5FURL?= =?UTF-8?q?=20dummy://=20=E2=86=92=20consolemail://=20(django-environ=20sc?= =?UTF-8?q?hema)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.prod.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index e4d8f3a0..28ad5e36 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -138,7 +138,7 @@ services: REDIS_URL: redis://redis:6379/2 SECRET_KEY: ${GLITCHTIP_SECRET} PORT: "8080" - EMAIL_URL: dummy:// + EMAIL_URL: consolemail:// GLITCHTIP_DOMAIN: https://errors.gendsgn.ru DEFAULT_FROM_EMAIL: errors@gendsgn.ru ENABLE_USER_REGISTRATION: "false" From f7047854288863c050d548e761b9b03abad6b40d Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 16 May 2026 21:02:00 +0300 Subject: [PATCH 030/342] fix(sql): escape apostrophe in migration 99 COMMENT (deploy hotfix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #222 deploy failed на 99_nspd_entities_denorm.sql: ERROR: syntax error at or near "а" LINE 2: '3-сегментный квартал последнего harvest'а. Используется... Unescaped apostrophe в COMMENT string closes literal early. Fix: harvest'а → harvest''а (SQL escape). Других unescaped апострофов в SQL литералах нет (другие 3 — в SQL комментариях '--' , безопасно). --- data/sql/99_nspd_entities_denorm.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/sql/99_nspd_entities_denorm.sql b/data/sql/99_nspd_entities_denorm.sql index c65a6f2f..9c674bdb 100644 --- a/data/sql/99_nspd_entities_denorm.sql +++ b/data/sql/99_nspd_entities_denorm.sql @@ -72,7 +72,7 @@ COMMENT ON COLUMN nspd_parcels.cad_num IS 'Кадастровый номер участка (4-сегментный). Natural PK из НСПД.'; COMMENT ON COLUMN nspd_parcels.quarter_cad IS - '3-сегментный квартал последнего harvest'а. Используется для debug/audit.'; + '3-сегментный квартал последнего harvest''а. Используется для debug/audit.'; COMMENT ON COLUMN nspd_parcels.cost_per_m2 IS 'Кадастровая стоимость за м² (cost_value / area_sqm). ' From e64fbd670bea726e319bf074aba208d9cc1909e6 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 16 May 2026 21:48:28 +0300 Subject: [PATCH 031/342] fix(infra): wire GlitchTip DSNs (build-arg + env_file) + reject sentry.io promote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem - GlitchTip Issues = 0 за всё время, хотя backend + frontend SDK интегрированы (PR #207, #208). - Старый Sentry.io продолжает получать события — user видит уведомления оттуда. Root cause - frontend/Dockerfile не имеет ARG NEXT_PUBLIC_GLITCHTIP_DSN → `npm run build` бьёт с пустым env var → Next.js инлайнит undefined → SDK init guard `if (dsn)` skips. Chrome-devtools check на prod bundle подтвердил: ни в одном из 9 chunks DSN-строка не запечена; `i.env.NEXT_PUBLIC_GLITCHTIP_DSN` evaluates to undefined. - .forgejo/workflows/deploy.yml build-frontend не передавал build-args. - На VPS backend/.env.runtime содержит legacy SENTRY_DSN=...@sentry.io/... config.py:_promote_legacy_sentry_dsn слепо промоутит его в glitchtip_dsn → SDK шлёт в чужой Sentry. GLITCHTIP_DSN там не задан. - deploy.yml SSH-скрипт никогда не редактировал SENTRY_DSN/GLITCHTIP_DSN в .env.runtime. Solution 1. frontend/Dockerfile: ARG NEXT_PUBLIC_GLITCHTIP_DSN + NEXT_PUBLIC_ENVIRONMENT с пустыми defaults, ENV-mirror перед `npm run build`. Локальный build без build-args работает по-прежнему (no-op DSN). 2. .forgejo/workflows/deploy.yml: - build-frontend: `build-args` передаёт `secrets.GLITCHTIP_FRONTEND_DSN` + NEXT_PUBLIC_ENVIRONMENT=production. Инвалидирует cache → frontend image пересоберётся (expected). - deploy step: GLITCHTIP_BACKEND_DSN через secret, в SSH-скрипте: a) `sed -i '/^SENTRY_DSN=/d' backend/.env.runtime` — снести legacy b) upsert GLITCHTIP_DSN (sed/printf) тем же паттерном что SENTRY_RELEASE c) `compose up -d --force-recreate --no-deps backend worker beat` — обычный `up -d` не перечитывает env_file без image change. 3. backend/app/core/config.py: _promote_legacy_sentry_dsn ужесточён — принимает SENTRY_DSN только если host == errors.gendsgn.ru. Для других URLs (sentry.io) выдаёт UserWarning и НЕ промоутит. Anti-regression на случай если SENTRY_DSN снова окажется в .env.runtime после ручного вмешательства. Required Forgejo secrets (Settings → Actions → Secrets) - GLITCHTIP_BACKEND_DSN = https://3d6e291003e142458957490c83559867@errors.gendsgn.ru/1 - GLITCHTIP_FRONTEND_DSN = https://5d7bc85e300c4e80a8554ccc818ff56d@errors.gendsgn.ru/2 DSNs публичны (видны в browser bundle) — secrets ради build-time injection, не для конфиденциальности. Если secrets не заданы → deploy succeeds, SDK no-op, без регрессии. Test plan - Verify Forgejo deploy.yml зелёный после merge - chrome-devtools: открыть gendsgn.ru → search bundle на DSN string → должна быть запечена строка errors.gendsgn.ru/2 - Trigger frontend error → POST к errors.gendsgn.ru/api/2/envelope/ - Backend: curl на endpoint вызывающий 500 → событие в GlitchTip backend project - GlitchTip dashboard https://errors.gendsgn.ru/gendesign/issues — Issues > 0 References - vault: meta/00_credentials.md (DSNs + incident notes 2026-05-16) - vault: decisions/Dec_GlitchTip_Frontend_Sentry_SDK.md (env contract) - vault: fixes/fixes-MOC.md (#204 backend SDK init) --- .forgejo/workflows/deploy.yml | 24 +++++++++++++++++++++++- backend/app/core/config.py | 31 ++++++++++++++++++++----------- frontend/Dockerfile | 6 ++++++ 3 files changed, 49 insertions(+), 12 deletions(-) diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index e9189f88..d140c43d 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -134,6 +134,9 @@ jobs: with: context: ./frontend push: true + build-args: | + NEXT_PUBLIC_GLITCHTIP_DSN=${{ secrets.GLITCHTIP_FRONTEND_DSN }} + NEXT_PUBLIC_ENVIRONMENT=production cache-from: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max tags: | @@ -156,12 +159,13 @@ jobs: IMAGE_TAG: latest SENTRY_RELEASE_VAL: ${{ github.sha }} GHCR_PAT: ${{ secrets.GHCR_PAT }} + GLITCHTIP_BACKEND_DSN: ${{ secrets.GLITCHTIP_BACKEND_DSN }} with: host: ${{ secrets.DEPLOY_HOST }} username: ${{ secrets.DEPLOY_USER }} key: ${{ secrets.DEPLOY_SSH_KEY }} port: ${{ secrets.DEPLOY_PORT }} - envs: IMAGE_TAG,SENTRY_RELEASE_VAL,GHCR_PAT + envs: IMAGE_TAG,SENTRY_RELEASE_VAL,GHCR_PAT,GLITCHTIP_BACKEND_DSN script: | set -euo pipefail cd /opt/gendesign @@ -182,6 +186,17 @@ jobs: else printf 'SENTRY_RELEASE=%s\n' "$SENTRY_RELEASE_VAL" >> backend/.env.runtime fi + + # GlitchTip wiring: убираем legacy SENTRY_DSN (auto-promote-логика в + # backend/app/core/config.py:_promote_legacy_sentry_dsn раньше брала + # его и слала события в чужой sentry.io). Устанавливаем GLITCHTIP_DSN + # из Forgejo secret. Пустой secret = no-op (SDK не инициализируется). + sed -i '/^SENTRY_DSN=/d' backend/.env.runtime + if grep -q '^GLITCHTIP_DSN=' backend/.env.runtime; then + sed -i "s|^GLITCHTIP_DSN=.*|GLITCHTIP_DSN=$GLITCHTIP_BACKEND_DSN|" backend/.env.runtime + else + printf 'GLITCHTIP_DSN=%s\n' "$GLITCHTIP_BACKEND_DSN" >> backend/.env.runtime + fi chmod 600 backend/.env.runtime # External network для Caddy + obsidian-stack share @@ -227,6 +242,13 @@ jobs: docker compose -p gendesign -f docker-compose.prod.yml up -d + # backend/.env.runtime изменения (SENTRY_RELEASE, GLITCHTIP_DSN) + # требуют --force-recreate — обычный `up -d` не перечитывает env_file + # если только image не сменился. На deploy где меняется только runtime + # без backend image change — без этого backend остаётся со старым DSN. + docker compose -p gendesign -f docker-compose.prod.yml up -d \ + --force-recreate --no-deps backend worker beat + docker compose -p gendesign -f docker-compose.prod.yml exec -T caddy \ caddy reload --config /etc/caddy/Caddyfile || true diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 16c29b34..4fea5f2c 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -20,22 +20,31 @@ class Settings(BaseSettings): @model_validator(mode="after") def _promote_legacy_sentry_dsn(self) -> "Settings": - """Backward-compat: если SENTRY_DSN задан, а GLITCHTIP_DSN пуст — автопродвигаем. + """Backward-compat: legacy SENTRY_DSN → glitchtip_dsn ТОЛЬКО для self-hosted. - На существующем VPS .env.runtime содержит SENTRY_DSN=https://... - (старое имя переменной). Pydantic extra="ignore" молча его отбрасывает, - SDK остаётся no-op. Читаем напрямую из os.environ и выдаём DeprecationWarning. + Старый VPS .env.runtime мог содержать SENTRY_DSN=https://...@sentry.io/... + (legacy SaaS Sentry). Промоутить его НЕЛЬЗЯ — события пойдут в чужой проект. + Принимаем только URLs указывающие на наш errors.gendsgn.ru host. """ if not self.glitchtip_dsn: legacy = os.getenv("SENTRY_DSN") if legacy: - warnings.warn( - "SENTRY_DSN is set but ignored by pydantic; rename to GLITCHTIP_DSN. " - "Auto-promoting for backward compat.", - DeprecationWarning, - stacklevel=2, - ) - self.glitchtip_dsn = legacy + if "errors.gendsgn.ru" in legacy: + warnings.warn( + "SENTRY_DSN is set but ignored by pydantic; rename to GLITCHTIP_DSN. " + "Auto-promoting for backward compat.", + DeprecationWarning, + stacklevel=2, + ) + self.glitchtip_dsn = legacy + else: + warnings.warn( + f"SENTRY_DSN points to non-GlitchTip host " + f"({legacy.split('@', 1)[-1][:40]}...) — ignoring. " + "Set GLITCHTIP_DSN explicitly.", + UserWarning, + stacklevel=2, + ) return self # External APIs (Stage 2) diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 6e34fff2..6c769b35 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -13,6 +13,12 @@ RUN --mount=type=cache,target=/root/.npm \ FROM node:20-alpine AS builder WORKDIR /app ENV NEXT_TELEMETRY_DISABLED=1 +# NEXT_PUBLIC_* must be present at `npm run build` — Next.js inlines them into +# the client bundle. Defaults to empty so local `docker build` без build-args не ломается. +ARG NEXT_PUBLIC_GLITCHTIP_DSN= +ARG NEXT_PUBLIC_ENVIRONMENT=production +ENV NEXT_PUBLIC_GLITCHTIP_DSN=$NEXT_PUBLIC_GLITCHTIP_DSN +ENV NEXT_PUBLIC_ENVIRONMENT=$NEXT_PUBLIC_ENVIRONMENT COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npm run build From 718a70f6e994081dd9df0e166f02c41a9763f4d6 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 16 May 2026 22:03:45 +0300 Subject: [PATCH 032/342] fix(beat): use /tmp/celerybeat-schedule (workdir /app not writable by app user) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beat был в restart-loop после force-recreate в PR #225 deploy: beat raised exception : error(13, 'Permission denied') shelve.py / dbm/__init__.py — попытка создать 'celerybeat-schedule' в CWD. Root cause - backend/Dockerfile runner stage: WORKDIR /app создаётся под root → owner root:root. - COPY --chown=app:app переносит файлы под app, но саму директорию /app не chown'ит. - USER app → app (uid 1000) не может создавать новые файлы в /app/ (нет write permission на parent dir). - До PR #225 beat container не пересоздавался force-recreate, файл schedule существовал в writable forme из inherited state. Force-recreate сбросил. Fix - --schedule=/tmp/celerybeat-schedule — /tmp всегда writable. - Schedule-файл хранит только last_run_at для periodic tasks; потеря на container restart OK, beat перестраивает из celery_app.conf.beat_schedule. Permanent fix (отдельный PR — runner Dockerfile) - Добавить `chown app:app /app` в runner stage после WORKDIR. Не делаю в этом PR — хотфикс минимальный, runner стейдж шарится с FastAPI image и worker, риск регрессии больше чем у однострочного --schedule override. --- docker-compose.prod.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 28ad5e36..316f6335 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -119,7 +119,12 @@ services: depends_on: redis: condition: service_healthy - command: ["celery", "-A", "app.workers.celery_app", "beat", "--loglevel=info"] + # --schedule=/tmp/...: default location `/app/celerybeat-schedule` падает + # с Permission denied — WORKDIR `/app` принадлежит root, `app` (uid 1000) + # не может создавать там файлы. `/tmp` всегда writable; schedule-файл + # хранит только last_run_at для periodic tasks — потеря на restart OK, + # beat перестроит из `celery_app.conf.beat_schedule` на старте. + command: ["celery", "-A", "app.workers.celery_app", "beat", "--loglevel=info", "--schedule=/tmp/celerybeat-schedule"] glitchtip-web: image: glitchtip/glitchtip:6.1.6 From 31581cedd2b708957533fca88ea98169a31e4dfa Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 16 May 2026 22:46:10 +0300 Subject: [PATCH 033/342] fix(#112): remove broken status='sold' filter from competitors avg_price query 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). --- .../app/services/site_finder/competitors.py | 6 ++-- .../tests/api/v1/test_parcel_competitors.py | 29 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/backend/app/services/site_finder/competitors.py b/backend/app/services/site_finder/competitors.py index cfba9d81..c59889d4 100644 --- a/backend/app/services/site_finder/competitors.py +++ b/backend/app/services/site_finder/competitors.py @@ -132,7 +132,10 @@ _COMPETITORS_SQL = text(""" ORDER BY d.distance_m ASC """) -# Средняя цена м² по проданным квартирам для набора obj_id +# Средняя цена м² по квартирам с известной ценой для набора 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, @@ -140,7 +143,6 @@ _AVG_PRICE_SQL = text(""" 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 """) diff --git a/backend/tests/api/v1/test_parcel_competitors.py b/backend/tests/api/v1/test_parcel_competitors.py index eacef554..6e16499b 100644 --- a/backend/tests/api/v1/test_parcel_competitors.py +++ b/backend/tests/api/v1/test_parcel_competitors.py @@ -334,6 +334,35 @@ def test_competitors_sold_pct_null() -> None: app.dependency_overrides.clear() +def test_competitors_avg_price_populated() -> None: + """avg_price_per_m2 не None если domrf_kn_flats возвращает строки с ценой. + + Регрессионный тест для Issue #227: фильтр status='sold' давал 0 строк + (поле status в domrf_kn_flats 99.8% NULL). После фикса — убран, AVG + считается по всем квартирам с known price_per_m2. + """ + rows = [_obj_row(obj_id=1)] + price_rows = [_price_row(obj_id=1, price=150_000.0)] + db = _make_db(coord=_coord_row(), obj_rows=rows, price_rows=price_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["avg_price_per_m2"] == pytest.approx( + 150_000.0 + ), "avg_price_per_m2 должен быть не None — регрессия #227 status='sold' filter" + finally: + app.dependency_overrides.clear() + + def test_competitors_is_active_flag() -> None: """is_active=True для sales/construction, False для completed/null.""" rows = [ From a6e4ff0407918106acdc1e24a918d3ed6db9abf1 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 16 May 2026 22:49:56 +0300 Subject: [PATCH 034/342] feat(#114): seed 3 default weight presets + include_system API param MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add SQL migration 100_user_weight_profiles_default_seed.sql with system presets Эконом/Комфорт/Бизнес (user_id='__system__'). Migration is idempotent via ON CONFLICT DO UPDATE. Backend: - weight_profiles.py: add SYSTEM_USER_ID constant + list_profiles_with_system() - admin_weight_profiles.py: add include_system query param to GET list endpoint Tests: 3 new tests covering include_system flag and service sentinel behaviour. --- backend/app/api/v1/admin_weight_profiles.py | 13 ++- .../services/site_finder/weight_profiles.py | 33 +++++++ backend/tests/test_admin_weight_profiles.py | 87 ++++++++++++++++++ .../100_user_weight_profiles_default_seed.sql | 90 +++++++++++++++++++ 4 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 data/sql/100_user_weight_profiles_default_seed.sql diff --git a/backend/app/api/v1/admin_weight_profiles.py b/backend/app/api/v1/admin_weight_profiles.py index 96b6d9b3..fc187596 100644 --- a/backend/app/api/v1/admin_weight_profiles.py +++ b/backend/app/api/v1/admin_weight_profiles.py @@ -26,6 +26,7 @@ from app.services.site_finder.weight_profiles import ( delete_profile, get_profile, list_profiles, + list_profiles_with_system, update_profile, ) @@ -37,8 +38,18 @@ def list_user_profiles( user_id: Annotated[str, Query(min_length=1, description="user_id для фильтра профилей")], db: Annotated[Session, Depends(get_db)], _: AdminTokenAuth, + include_system: Annotated[ + bool, + Query(description="Включить системные preset-профили (Эконом/Комфорт/Бизнес)"), + ] = False, ) -> Any: - """Список всех weight profiles для заданного user_id. Default-профиль первым.""" + """Список всех weight profiles для заданного user_id. Default-профиль первым. + + При include_system=true добавляются системные presets (user_id='__system__'): + Эконом, Комфорт, Бизнес — готовые шаблоны весов POI. + """ + if include_system: + return list_profiles_with_system(db, user_id) return list_profiles(db, user_id) diff --git a/backend/app/services/site_finder/weight_profiles.py b/backend/app/services/site_finder/weight_profiles.py index 12259896..08a253d1 100644 --- a/backend/app/services/site_finder/weight_profiles.py +++ b/backend/app/services/site_finder/weight_profiles.py @@ -26,6 +26,10 @@ from sqlalchemy import text logger = logging.getLogger(__name__) +# Sentinel user_id для системных preset-профилей (не привязаны к реальному пользователю). +# Seed: data/sql/100_user_weight_profiles_default_seed.sql +SYSTEM_USER_ID: str = "__system__" + # Allowed POI categories — single source of truth; imported by api/v1/parcels.py ALLOWED_CATEGORIES: set[str] = { "school", @@ -74,6 +78,17 @@ _SELECT_BY_USER = f""" ORDER BY is_default DESC, id ASC """ +_SELECT_BY_USER_WITH_SYSTEM = f""" + SELECT {_SELECT_COLS} + FROM user_weight_profiles + WHERE user_id = :user_id + OR user_id = :system_user_id + ORDER BY + CASE WHEN user_id = :system_user_id THEN 1 ELSE 0 END ASC, + is_default DESC, + id ASC +""" + _SELECT_BY_ID = f""" SELECT {_SELECT_COLS} FROM user_weight_profiles @@ -191,6 +206,24 @@ def list_profiles(db: Any, user_id: str) -> list[WeightProfile]: return [_row_to_profile(r) for r in rows] +def list_profiles_with_system(db: Any, user_id: str) -> list[WeightProfile]: + """Вернуть профили пользователя + системные preset-профили. + + Пользовательские профили идут первыми (default сверху), затем системные + presets (Эконом, Комфорт, Бизнес). Предназначен для endpoint с + include_system=true — UI dropdown видит и пользовательские, и preset. + """ + rows = ( + db.execute( + text(_SELECT_BY_USER_WITH_SYSTEM), + {"user_id": user_id, "system_user_id": SYSTEM_USER_ID}, + ) + .mappings() + .all() + ) + return [_row_to_profile(r) for r in rows] + + def get_profile(db: Any, user_id: str, profile_id: int) -> WeightProfile | None: """Вернуть профиль по id (scoped к пользователю).""" row = ( diff --git a/backend/tests/test_admin_weight_profiles.py b/backend/tests/test_admin_weight_profiles.py index 01caa3ed..c3bad23a 100644 --- a/backend/tests/test_admin_weight_profiles.py +++ b/backend/tests/test_admin_weight_profiles.py @@ -314,6 +314,93 @@ def test_delete_not_found(client_with_token: TestClient, monkeypatch: pytest.Mon _clear_overrides() +# ── include_system preset seed (Issue #114 / PR #229) ───────────────────────── + + +def test_list_include_system_calls_with_system( + client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """GET ?include_system=true вызывает list_profiles_with_system, возвращает presets.""" + system_profile = _make_profile( + 100, user_id="__system__", profile_name="Комфорт", weights={"park": 1.8, "school": 1.5} + ) + user_profile = _make_profile(1, user_id="user-1", profile_name="Мой профиль") + mock = MagicMock() + _override_db(mock) + try: + monkeypatch.setattr( + "app.api.v1.admin_weight_profiles.list_profiles_with_system", + lambda db, user_id: [user_profile, system_profile], + ) + r = client_with_token.get( + "/api/v1/admin/site-finder/weight-profiles", + params={"user_id": "user-1", "include_system": "true"}, + headers=_HEADERS, + ) + assert r.status_code == 200 + body = r.json() + assert len(body) == 2 + profile_names = {p["profile_name"] for p in body} + assert "Комфорт" in profile_names + assert "Мой профиль" in profile_names + finally: + _clear_overrides() + + +def test_list_without_include_system_does_not_call_with_system( + client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """GET без include_system → list_profiles (только пользовательские профили).""" + user_profile = _make_profile(1, user_id="user-1") + called_with_system = [] + mock = MagicMock() + _override_db(mock) + try: + monkeypatch.setattr( + "app.api.v1.admin_weight_profiles.list_profiles", + lambda db, user_id: [user_profile], + ) + monkeypatch.setattr( + "app.api.v1.admin_weight_profiles.list_profiles_with_system", + lambda db, user_id: called_with_system.append(True) or [], + ) + r = client_with_token.get( + "/api/v1/admin/site-finder/weight-profiles", + params={"user_id": "user-1"}, + headers=_HEADERS, + ) + assert r.status_code == 200 + assert len(r.json()) == 1 + # list_profiles_with_system НЕ должен вызываться без include_system=true + assert called_with_system == [], "вызван list_profiles_with_system без флага" + finally: + _clear_overrides() + + +def test_list_profiles_with_system_service(monkeypatch: pytest.MonkeyPatch) -> None: + """list_profiles_with_system передаёт system_user_id='__system__' в запрос.""" + from app.services.site_finder.weight_profiles import SYSTEM_USER_ID, list_profiles_with_system + + captured_params: list[dict] = [] + + class FakeResult: + def mappings(self) -> FakeResult: + return self + + def all(self) -> list: + return [] + + class FakeDb: + def execute(self, query: object, params: dict) -> FakeResult: + captured_params.append(params) + return FakeResult() + + list_profiles_with_system(FakeDb(), user_id="user-test") + assert len(captured_params) == 1 + assert captured_params[0]["system_user_id"] == SYSTEM_USER_ID + assert captured_params[0]["user_id"] == "user-test" + + # ── Auth ─────────────────────────────────────────────────────────────────────── diff --git a/data/sql/100_user_weight_profiles_default_seed.sql b/data/sql/100_user_weight_profiles_default_seed.sql new file mode 100644 index 00000000..546061cf --- /dev/null +++ b/data/sql/100_user_weight_profiles_default_seed.sql @@ -0,0 +1,90 @@ +-- 100_user_weight_profiles_default_seed.sql +-- Системные preset-профили весов POI для Site Finder. +-- Per #114 (Макс feedback): садики и Мегамарт должны иметь разные веса. +-- +-- Apply order: после 90_user_weight_profiles.sql (таблица уже существует) +-- Dependencies: user_weight_profiles table +-- +-- Sentinel owner: '__system__' — не является реальным user_id, +-- используется только для системных presets. +-- UI: dropdown показывает system presets всем пользователям через +-- GET /api/v1/admin/site-finder/weight-profiles?user_id=__system__ +-- или include_system=true (добавлен в #114 seed PR). +-- +-- Idempotent: ON CONFLICT (user_id, profile_name) DO UPDATE — обновляет +-- веса если preset уже существует (safe re-apply на prod). + +BEGIN; + +INSERT INTO user_weight_profiles + (user_id, profile_name, weights, is_default, description) +VALUES + ( + '__system__', + 'Эконом', + '{ + "school": 1.2, + "kindergarten": 1.0, + "pharmacy": 0.8, + "hospital": 0.5, + "shop_mall": 0.8, + "shop_supermarket": 1.5, + "shop_small": 1.0, + "park": 1.2, + "bus_stop": 1.0, + "metro_stop": 0.8, + "tram_stop": 0.5 + }'::jsonb, + FALSE, + 'Эконом-класс: доступность магазинов и транспорта важнее премиальной инфраструктуры' + ), + ( + '__system__', + 'Комфорт', + '{ + "school": 1.5, + "kindergarten": 1.8, + "pharmacy": 0.8, + "hospital": 0.6, + "shop_mall": 1.0, + "shop_supermarket": 1.2, + "shop_small": 0.5, + "park": 1.8, + "bus_stop": 0.5, + "metro_stop": 1.2, + "tram_stop": -0.3 + }'::jsonb, + FALSE, + 'Комфорт-класс: парки, детсады и школы в приоритете — семейная аудитория' + ), + ( + '__system__', + 'Бизнес', + '{ + "school": 1.0, + "kindergarten": 0.8, + "pharmacy": 0.6, + "hospital": 0.5, + "shop_mall": 2.0, + "shop_supermarket": 0.8, + "shop_small": 0.3, + "park": 2.0, + "bus_stop": 0.2, + "metro_stop": 2.5, + "tram_stop": -0.5 + }'::jsonb, + FALSE, + 'Бизнес-класс: метро, ТЦ и парки в приоритете — платёжеспособная аудитория' + ) +ON CONFLICT (user_id, profile_name) +DO UPDATE SET + weights = EXCLUDED.weights, + description = EXCLUDED.description, + updated_at = NOW(); + +COMMENT ON TABLE user_weight_profiles IS + 'POI weight profiles для site_finder. Per #114 (Макс feedback). ' + 'weights = {"school": 1.5, "kindergarten": 1.5, "shop_mall": 1.2, ...}. ' + 'Системные presets: user_id = ''__system__'' (3 preset: Эконом/Комфорт/Бизнес).'; + +COMMIT; From a7671513c9e70929a1928cc5f41deb979a5153bc Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 16 May 2026 22:51:12 +0300 Subject: [PATCH 035/342] =?UTF-8?q?feat(#115):=20Leaflet=20layer=20toggle?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20connection=20points=20(=D0=9C=D0=B0?= =?UTF-8?q?=D0=BA=D1=81=20KILLER)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ConnectionPointsLayer.tsx — per-category CircleMarker слой внутри MapContainer (electricity/gas/water/heat/sewage/telecom/other, классификация по keywords) - CpLayerControlPanel.tsx — toggle panel под картой: checkbox per-category + count, toggle-all, summary badges (ближайший, охранная зона) - SiteMap.tsx — принимает connectionPoints?: ConnectionPointsResponse, управляет visibleCategories state, рендерит оба новых компонента - page.tsx — useConnectionPoints(data?.cad_num) -> передаёт в SiteMap - Empty-state: "0 точек подключения" при dump_available=false или пустом ответе --- frontend/src/app/site-finder/page.tsx | 10 +- .../site-finder/ConnectionPointsLayer.tsx | 224 ++++++++++++++++++ .../site-finder/CpLayerControlPanel.tsx | 224 ++++++++++++++++++ .../src/components/site-finder/SiteMap.tsx | 65 ++++- 4 files changed, 520 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/site-finder/ConnectionPointsLayer.tsx create mode 100644 frontend/src/components/site-finder/CpLayerControlPanel.tsx diff --git a/frontend/src/app/site-finder/page.tsx b/frontend/src/app/site-finder/page.tsx index baa15870..460a9c00 100644 --- a/frontend/src/app/site-finder/page.tsx +++ b/frontend/src/app/site-finder/page.tsx @@ -16,6 +16,7 @@ import { MarketTab } from "@/components/site-finder/MarketTab"; import { WeightProfilePanel } from "@/components/site-finder/WeightProfilePanel"; import { useSiteAnalysis } from "@/hooks/useSiteAnalysis"; import { useDebouncedValue } from "@/hooks/useDebouncedValue"; +import { useConnectionPoints } from "@/hooks/useConnectionPoints"; import { POI_DEFAULT_WEIGHTS, type PoiCategoryKey, @@ -98,6 +99,9 @@ function SiteFinderContent() { undefined, ); + // Fetch connection points whenever a parcel is loaded + const { data: connectionPoints } = useConnectionPoints(data?.cad_num); + // Weight profile state — lifted here so it survives tab switches. // userId + adminToken allow the panel to load/save named profiles. const [currentWeights, setCurrentWeights] = useState< @@ -537,7 +541,11 @@ function SiteFinderContent() { gap: 12, }} > - +
diff --git a/frontend/src/components/site-finder/ConnectionPointsLayer.tsx b/frontend/src/components/site-finder/ConnectionPointsLayer.tsx new file mode 100644 index 00000000..1e25173a --- /dev/null +++ b/frontend/src/components/site-finder/ConnectionPointsLayer.tsx @@ -0,0 +1,224 @@ +"use client"; + +import { CircleMarker, Popup, LayerGroup } from "react-leaflet"; + +import type { EngineeringStructure } from "@/types/nspd"; + +// --------------------------------------------------------------------------- +// Category classification +// --------------------------------------------------------------------------- + +export type CpCategory = + | "electricity" + | "gas" + | "water" + | "heat" + | "sewage" + | "telecom" + | "other"; + +export interface CpCategoryStyle { + color: string; + label: string; + radius: number; +} + +export const CP_CATEGORY_STYLES: Record = { + electricity: { color: "#f59e0b", label: "Электричество", radius: 9 }, + gas: { color: "#3b82f6", label: "Газ", radius: 9 }, + water: { color: "#06b6d4", label: "Вода", radius: 8 }, + heat: { color: "#ef4444", label: "Теплоснабжение", radius: 8 }, + sewage: { color: "#8b5cf6", label: "Канализация", radius: 8 }, + telecom: { color: "#10b981", label: "Связь", radius: 7 }, + other: { color: "#6b7280", label: "Другое", radius: 7 }, +}; + +export const CP_ALL_CATEGORIES = Object.keys( + CP_CATEGORY_STYLES, +) as CpCategory[]; + +// Keywords to match against `type` and `name` fields (case-insensitive) +const CATEGORY_KEYWORDS: Array<{ cat: CpCategory; keywords: string[] }> = [ + { + cat: "electricity", + keywords: [ + "трансформатор", + "тп-", + "ктп", + "подстанция", + "электр", + "tp-", + "лэп", + "36328", + ], + }, + { + cat: "gas", + keywords: ["газ", "гтс", "газоп", "газорегул", "газопровод"], + }, + { + cat: "water", + keywords: ["водо", "водопр", "насосн", "колодец", "скважин"], + }, + { + cat: "heat", + keywords: ["тепло", "теплос", "котельн", "тэц"], + }, + { + cat: "sewage", + keywords: ["канал", "сток", "ливнев", "кнс", "канализ"], + }, + { + cat: "telecom", + keywords: ["связ", "телеком", "интернет", "оптик", "вышка"], + }, +]; + +export function classifyStructure(s: EngineeringStructure): CpCategory { + const haystack = + `${s.name ?? ""} ${s.type ?? ""} ${s.source ?? ""}`.toLowerCase(); + for (const { cat, keywords } of CATEGORY_KEYWORDS) { + if (keywords.some((kw) => haystack.includes(kw))) return cat; + } + return "other"; +} + +// --------------------------------------------------------------------------- +// Group helper (exported so SiteMap can build counts for the control panel) +// --------------------------------------------------------------------------- + +export function groupStructuresByCategory( + structures: EngineeringStructure[], +): Map { + const grouped = new Map(); + for (const cat of CP_ALL_CATEGORIES) { + grouped.set(cat, []); + } + for (const s of structures) { + const cat = classifyStructure(s); + grouped.get(cat)!.push(s); + } + return grouped; +} + +// --------------------------------------------------------------------------- +// Geometry helpers +// --------------------------------------------------------------------------- + +function extractLatLon( + geojson: Record, +): [number, number] | null { + if (geojson.type === "Point") { + const coords = geojson.coordinates as number[] | undefined; + if (coords && coords.length >= 2) { + // GeoJSON: [lon, lat] + return [coords[1], coords[0]]; + } + } + return null; +} + +// --------------------------------------------------------------------------- +// Map layer — renders CircleMarkers inside the Leaflet MapContainer +// --------------------------------------------------------------------------- + +interface LayerProps { + visibleCategories: Set; + grouped: Map; +} + +export function ConnectionPointsLayer({ + visibleCategories, + grouped, +}: LayerProps) { + return ( + <> + {CP_ALL_CATEGORIES.map((cat) => { + if (!visibleCategories.has(cat)) return null; + const structs = grouped.get(cat) ?? []; + const style = CP_CATEGORY_STYLES[cat]; + + return ( + + {structs.map((s, idx) => { + const latLon = extractLatLon(s.geometry_geojson); + if (!latLon) return null; + + return ( + + +
+
+ + {style.label} +
+
+ {s.name ?? s.type ?? "Объект"} +
+ {s.type && s.name && ( +
+ {s.type} +
+ )} + {s.readable_address && ( +
+ {s.readable_address} +
+ )} +
+ До границы:{" "} + + {Math.round(s.distance_to_boundary_m)} м + +
+
+ Источник: {s.source} +
+
+
+
+ ); + })} +
+ ); + })} + + ); +} diff --git a/frontend/src/components/site-finder/CpLayerControlPanel.tsx b/frontend/src/components/site-finder/CpLayerControlPanel.tsx new file mode 100644 index 00000000..df16d14b --- /dev/null +++ b/frontend/src/components/site-finder/CpLayerControlPanel.tsx @@ -0,0 +1,224 @@ +"use client"; + +import { useState } from "react"; + +import type { + ConnectionPointsResponse, + EngineeringStructure, +} from "@/types/nspd"; +import { + CP_ALL_CATEGORIES, + CP_CATEGORY_STYLES, + type CpCategory, +} from "@/components/site-finder/ConnectionPointsLayer"; + +interface Props { + data: ConnectionPointsResponse; + grouped: Map; + visibleCategories: Set; + onToggleCategory: (cat: CpCategory) => void; + onToggleAll: () => void; +} + +export function CpLayerControlPanel({ + data, + grouped, + visibleCategories, + onToggleCategory, + onToggleAll, +}: Props) { + const [collapsed, setCollapsed] = useState(false); + + const totalCount = data.engineering_structures.length; + const allVisible = visibleCategories.size === CP_ALL_CATEGORIES.length; + + return ( +
+ {/* Header */} +
setCollapsed((v) => !v)} + > + + Точки подключения + + + {totalCount} шт {collapsed ? "▲" : "▼"} + +
+ + {!collapsed && ( +
+ {/* No dump */} + {!data.dump_available && ( +
+ Дамп квартала не загружен — 0 точек подключения +
+ )} + + {/* Empty state */} + {data.dump_available && totalCount === 0 && ( +
+ 0 точек подключения в этом квартале +
+ )} + + {/* Toggle-all */} + {totalCount > 0 && ( + + )} + + {/* Per-category */} +
+ {CP_ALL_CATEGORIES.map((cat) => { + const structs = grouped.get(cat) ?? []; + const style = CP_CATEGORY_STYLES[cat]; + if (structs.length === 0) return null; + const active = visibleCategories.has(cat); + return ( + + ); + })} +
+ + {/* Summary */} + {data.dump_available && totalCount > 0 && ( +
+ {data.summary.nearest_structure_distance_m !== null && ( + + Ближайший:{" "} + {Math.round(data.summary.nearest_structure_distance_m)} м + + )} + {data.summary.in_protection_zone && ( + + В охранной зоне + + )} + {data.summary.protection_zones_intersecting > 0 && + !data.summary.in_protection_zone && ( + + Охранных зон: {data.summary.protection_zones_intersecting} + + )} +
+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/site-finder/SiteMap.tsx b/frontend/src/components/site-finder/SiteMap.tsx index 9c007246..5b81b8d1 100644 --- a/frontend/src/components/site-finder/SiteMap.tsx +++ b/frontend/src/components/site-finder/SiteMap.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { MapContainer, TileLayer, @@ -12,6 +12,17 @@ import type { Feature, FeatureCollection, Geometry, Position } from "geojson"; import "leaflet/dist/leaflet.css"; import type { ParcelAnalysis } from "@/types/site-finder"; +import type { + ConnectionPointsResponse, + EngineeringStructure, +} from "@/types/nspd"; +import { + ConnectionPointsLayer, + CP_ALL_CATEGORIES, + groupStructuresByCategory, + type CpCategory, +} from "@/components/site-finder/ConnectionPointsLayer"; +import { CpLayerControlPanel } from "@/components/site-finder/CpLayerControlPanel"; // --------------------------------------------------------------------------- // POI legend config (for the legend row below the map) @@ -83,9 +94,10 @@ function geomCenter(geom: Geometry): [number, number] { interface Props { data: ParcelAnalysis; isochrones?: FeatureCollection; + connectionPoints?: ConnectionPointsResponse; } -export function SiteMap({ data, isochrones }: Props) { +export function SiteMap({ data, isochrones, connectionPoints }: Props) { // Fix Leaflet default icon paths broken by webpack bundler useEffect(() => { void import("leaflet").then((L) => { @@ -101,6 +113,31 @@ export function SiteMap({ data, isochrones }: Props) { }); }, []); + // Connection-points layer toggle state (all categories visible by default) + const [visibleCategories, setVisibleCategories] = useState>( + new Set(CP_ALL_CATEGORIES), + ); + + function toggleCategory(cat: CpCategory) { + setVisibleCategories((prev) => { + const next = new Set(prev); + if (next.has(cat)) { + next.delete(cat); + } else { + next.add(cat); + } + return next; + }); + } + + function toggleAll() { + setVisibleCategories((prev) => + prev.size === CP_ALL_CATEGORIES.length + ? new Set() + : new Set(CP_ALL_CATEGORIES), + ); + } + const center: [number, number] = data.geom_geojson ? geomCenter(data.geom_geojson) : [56.838, 60.6]; @@ -108,6 +145,11 @@ export function SiteMap({ data, isochrones }: Props) { // Build legend from categories present in score_breakdown const presentCategories = Object.keys(data.score_breakdown); + // Pre-group structures for both the map layer and the control panel + const cpGrouped = connectionPoints + ? groupStructuresByCategory(connectionPoints.engineering_structures) + : new Map(); + return (
{/* Map */} @@ -222,6 +264,14 @@ export function SiteMap({ data, isochrones }: Props) { )); })} + + {/* Connection points layer — rendered on top of POI markers */} + {connectionPoints && ( + + )}
@@ -278,6 +328,17 @@ export function SiteMap({ data, isochrones }: Props) { Геометрия участка не найдена — на карте нет полигона

)} + + {/* Connection points layer control panel — below the map */} + {connectionPoints && ( + + )}
); } From 0135f3c7c4a161bb4290c047a78badf604022bda Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 17 May 2026 08:13:32 +0300 Subject: [PATCH 036/342] feat(#104): add POST /admin/scrape/ekburg-permits for manual trigger Wraps refresh_all / refresh_year Celery tasks behind the existing AdminTokenAuth gate so the table can be populated on demand after first deploy instead of waiting for the monthly beat (1st of month). - TriggerEkburgPermitsRequest: year int|None, ge=2022 le=2030 - year=None -> refresh_all.apply_async() (scope all_years_2022_2026) - year=N -> refresh_year.apply_async(args=[N]) (scope year_N) - 4 smoke tests: all/year/invalid_year/no_token --- backend/app/api/v1/admin_scrape.py | 40 +++++++++ .../tests/api/v1/test_admin_ekburg_permits.py | 82 +++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 backend/tests/api/v1/test_admin_ekburg_permits.py diff --git a/backend/app/api/v1/admin_scrape.py b/backend/app/api/v1/admin_scrape.py index cd611f4a..d9606667 100644 --- a/backend/app/api/v1/admin_scrape.py +++ b/backend/app/api/v1/admin_scrape.py @@ -1209,3 +1209,43 @@ def list_runs( } for r in rows ] + + +# ── Ekburg construction permits (РНС/РВЭ ЕКБ) ──────────────────────────────── + + +class TriggerEkburgPermitsRequest(BaseModel): + year: int | None = Field( + default=None, + ge=2022, + le=2030, + description=( + "Если None — refresh_all (все 5 лет 2022-2026). " + "Иначе refresh_year(year) для конкретного года." + ), + ) + + +@router.post("/ekburg-permits") +def trigger_ekburg_permits( + payload: TriggerEkburgPermitsRequest, + _: AdminTokenAuth, +) -> dict[str, Any]: + """Manual trigger для синхронизации РНС/РВЭ ЕКБ из екатеринбург.рф (xlsx). + + Обычно запускается раз в месяц через beat (1-го числа 02:00 UTC). + Этот endpoint — для ad-hoc запуска (после первого деплоя или для smoke-теста). + + - year=None → refresh_all: скачивает 5 xlsx (2022-2026), ~3-8 мин. + - year=2026 → refresh_year(2026): один файл, ~1 мин. + """ + from app.workers.tasks.ekburg_permits_sync import refresh_all, refresh_year + + if payload.year is None: + result = refresh_all.apply_async() + scope = "all_years_2022_2026" + else: + result = refresh_year.apply_async(args=[payload.year]) + scope = f"year_{payload.year}" + + return {"task_id": result.id, "scope": scope, "queued_at": "now"} diff --git a/backend/tests/api/v1/test_admin_ekburg_permits.py b/backend/tests/api/v1/test_admin_ekburg_permits.py new file mode 100644 index 00000000..a03c10be --- /dev/null +++ b/backend/tests/api/v1/test_admin_ekburg_permits.py @@ -0,0 +1,82 @@ +"""Тесты для POST /admin/scrape/ekburg-permits. + +Проверяет: +- валидный запрос без year → scope all_years_2022_2026, task_id в ответе +- валидный запрос с year=2026 → scope year_2026 +- year < 2022 или > 2030 → 422 +- отсутствие X-Admin-Token → 401/503 +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +from app.main import app + +ADMIN_TOKEN = "test-admin-token" +ADMIN_HEADERS = {"X-Admin-Token": ADMIN_TOKEN} +ENDPOINT = "/api/v1/admin/scrape/ekburg-permits" + + +def _mock_task(task_id: str = "fake-task-id-123") -> MagicMock: + result = MagicMock() + result.id = task_id + return result + + +@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) +def test_trigger_refresh_all_returns_task_id() -> None: + """POST без year → refresh_all queued, scope=all_years_2022_2026.""" + mock_result = _mock_task("task-all-001") + + with ( + patch("app.workers.tasks.ekburg_permits_sync.refresh_all") as mock_refresh_all, + patch("app.workers.tasks.ekburg_permits_sync.refresh_year"), + ): + mock_refresh_all.apply_async.return_value = mock_result + client = TestClient(app) + response = client.post(ENDPOINT, json={}, headers=ADMIN_HEADERS) + + assert response.status_code == 200, response.text + body = response.json() + assert body["task_id"] == "task-all-001" + assert body["scope"] == "all_years_2022_2026" + assert "queued_at" in body + + +@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) +def test_trigger_refresh_year_returns_task_id() -> None: + """POST year=2026 → refresh_year queued, scope=year_2026.""" + mock_result = _mock_task("task-year-002") + + with ( + patch("app.workers.tasks.ekburg_permits_sync.refresh_year") as mock_refresh_year, + patch("app.workers.tasks.ekburg_permits_sync.refresh_all"), + ): + mock_refresh_year.apply_async.return_value = mock_result + client = TestClient(app) + response = client.post(ENDPOINT, json={"year": 2026}, headers=ADMIN_HEADERS) + + assert response.status_code == 200, response.text + body = response.json() + assert body["task_id"] == "task-year-002" + assert body["scope"] == "year_2026" + + +@pytest.mark.parametrize("bad_year", [2021, 2031, 1999, 9999]) +@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) +def test_trigger_invalid_year_returns_422(bad_year: int) -> None: + """year вне диапазона [2022, 2030] → 422 Unprocessable Entity.""" + client = TestClient(app) + response = client.post(ENDPOINT, json={"year": bad_year}, headers=ADMIN_HEADERS) + assert response.status_code == 422, f"year={bad_year} должен возвращать 422" + + +def test_trigger_no_token_returns_401_or_503() -> None: + """Без X-Admin-Token → 401 или 503.""" + client = TestClient(app) + response = client.post(ENDPOINT, json={}) + assert response.status_code in (401, 503), response.text From f70be684da675bc1f646ab47fac1fca2fc3dcf92 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 17 May 2026 08:17:22 +0300 Subject: [PATCH 037/342] feat(#29,#232): wire cad_parcels.permitted_use in analyze + cad_zouit fallback (G2+G3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TASK A (#29 G2): add parcel_meta to analyze response - New ParcelMeta Pydantic schema in app/schemas/parcel.py - SELECT from cad_parcels WHERE cad_num=:c in analyze_parcel() (step 9f) - Returns permitted_use_established_by_document, land_record_category_type, land_record_subtype, cost_value; None when row absent - Tests: test_analyze_parcel_meta.py (found + not-found cases) TASK B (#232 G3): cad_zouit fallback in _get_zouit_overlaps - When nspd_quarter_dumps has zouit_count==0, fall back to ST_Intersects query on cad_zouit (3483 rows, GIST indexed) - Overlaps tagged with source='cad_zouit'; format compatible with NSPD path - gate_verdict.py: BLOCKER_TYPE_ZONE_KEYWORDS tuple for keyword-based classification (охранная зона / трубопровод / электр / газ -> blocker; СЗЗ -> warning); NSPD subcategory path preserved backward-compat - Tests: 6 new test cases in test_gate_verdict.py covering cad_zouit path and backward-compat for NSPD subcategory path Updated db.execute call sequence in test_analyze_*.py (index shift +1 at pos 10). --- backend/app/api/v1/parcels.py | 32 +++ backend/app/schemas/parcel.py | 16 ++ .../app/services/site_finder/gate_verdict.py | 76 +++++-- .../site_finder/quarter_dump_lookup.py | 116 +++++++--- .../api/v1/test_analyze_inline_weights.py | 30 +-- .../tests/api/v1/test_analyze_market_price.py | 35 +-- .../tests/api/v1/test_analyze_parcel_meta.py | 210 ++++++++++++++++++ .../api/v1/test_analyze_recent_permits.py | 36 +-- backend/tests/test_gate_verdict.py | 92 +++++++- 9 files changed, 551 insertions(+), 92 deletions(-) create mode 100644 backend/tests/api/v1/test_analyze_parcel_meta.py diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index ba80c31c..8f0064a4 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -23,6 +23,7 @@ from app.schemas.parcel import ( ConnectionPointsResponse, OpportunityParcel, ParcelDetail, + ParcelMeta, ParcelSearchRequest, ParcelSearchResponse, RedLine, @@ -1627,6 +1628,35 @@ def analyze_parcel( # Independent dict per request — never mutate module singleton. nspd_dump_data = make_empty_result() + # 9f) Parcel meta — ВРИ и кадастровые метаданные из cad_parcels (#29 G2) + parcel_meta: ParcelMeta | None = None + try: + pm_row = ( + db.execute( + text(""" + SELECT permitted_use_established_by_document AS permitted_use, + land_record_category_type AS land_category, + land_record_subtype AS land_subtype, + cost_value AS cad_cost + FROM cad_parcels + WHERE cad_num = CAST(:c AS text) + LIMIT 1 + """), + {"c": cad_num}, + ) + .mappings() + .first() + ) + if pm_row: + parcel_meta = ParcelMeta( + permitted_use=pm_row["permitted_use"], + land_category=pm_row["land_category"], + land_subtype=pm_row["land_subtype"], + cad_cost=float(pm_row["cad_cost"]) if pm_row["cad_cost"] is not None else None, + ) + except Exception as e: + logger.warning("parcel_meta query failed for %s: %s", cad_num, e) + # 10) Market trend — динамика цен ДДУ в радиусе 3 км за 6 vs предыдущие 6 месяцев market_trend: dict[str, Any] | None = None try: @@ -2051,6 +2081,8 @@ def analyze_parcel( "market_trend": market_trend, # #33 D2: квартальная ценовая статистика из mv_quarter_price_per_m2 "market_price": market_price, + # #29 G2: кадастровые метаданные участка (ВРИ, категория, кад. стоимость) + "parcel_meta": parcel_meta.model_dump() if parcel_meta is not None else None, # #105 Phase 5: РНС/РВЭ в квартале (quarter prefix match; после Phase 3 → spatial 500m) "recent_permits_in_quarter": recent_permits, "permits_summary": permits_summary, diff --git a/backend/app/schemas/parcel.py b/backend/app/schemas/parcel.py index 8d210704..2452bc99 100644 --- a/backend/app/schemas/parcel.py +++ b/backend/app/schemas/parcel.py @@ -315,6 +315,22 @@ class MarketPrice(BaseModel): source: Literal["quarter_mv", "no_data"] = "no_data" +# ── Analyze endpoint parcel meta (#29 G2) ──────────────────────────────────── + + +class ParcelMeta(BaseModel): + """Кадастровые метаданные участка из cad_parcels (#29 G2). + + Поля берутся из NSPD bulk-ingest (issue #168). Строки сырые, без нормализации. + """ + + permitted_use: str | None = None # permitted_use_established_by_document + land_category: str | None = None # land_record_category_type + land_subtype: str | None = None # land_record_subtype + cad_cost: float | None = None # cost_value (кадастровая стоимость, руб.) + source: str = "cad_parcels" + + # ── Analyze endpoint inline weights (#201) ──────────────────────────────────── diff --git a/backend/app/services/site_finder/gate_verdict.py b/backend/app/services/site_finder/gate_verdict.py index 209cbeda..8b854cb3 100644 --- a/backend/app/services/site_finder/gate_verdict.py +++ b/backend/app/services/site_finder/gate_verdict.py @@ -18,11 +18,25 @@ RESIDENTIAL_KEYWORDS = ("жил",) # ── ЗОУИТ taxonomy ──────────────────────────────────────────────────────────── -# Subcategory codes которые блокируют МКД (охранные зоны ЛЭП/газа/трубопровода) +# Subcategory codes которые блокируют МКД (охранные зоны ЛЭП/газа/трубопровода). +# Используется для overlaps из NSPD dump (subcategory INT там заполнен). BLOCKER_SUBCATEGORIES: dict[int, str] = { 17: "Инженерные коммуникации (охранная зона ЛЭП/газа/трубопровода)", } +# Keyword-based blockers для cad_zouit fallback (#232). +# cad_zouit.subcategory = 100% NULL, поэтому классификация по type_zone substring. +# Порядок: lowercase, substring match (any wins → blocker). +BLOCKER_TYPE_ZONE_KEYWORDS: tuple[str, ...] = ( + "охранная зона", + "трубопровод", + "электр", + "газ", +) + +# type_zone substrings, которые дают warning (не blocker) из cad_zouit. +WARNING_TYPE_ZONE_KEYWORDS: tuple[str, ...] = ("сзз", "санитарно-защитная") + # Engineering nearby search radius (метры) — совпадает с quarter_dump_lookup.py ENGINEERING_NEARBY_THRESHOLD_M = 200 @@ -136,21 +150,57 @@ def compute_gate_verdict( # Check 2 — ЗОУИТ overlaps checks.append("ЗОУИТ пересечения") for overlap in nspd_zouit_overlaps or []: - sub = overlap.get("subcategory") - if isinstance(sub, int) and sub in BLOCKER_SUBCATEGORIES: - blockers.append( - Blocker( - code=f"ZOUIT_OVERLAP_SUB{sub}", - detail=f"{BLOCKER_SUBCATEGORIES[sub]}: {overlap.get('name', '')}", + src = overlap.get("source", "nspd-quarter-dump") + if src == "cad_zouit": + # cad_zouit fallback path: classify by type_zone keywords (#232). + # subcategory = NULL в cad_zouit, поэтому subcategory-based logic не применяется. + type_zone_lower = (overlap.get("type_zone") or overlap.get("layer") or "").lower() + if any(kw in type_zone_lower for kw in BLOCKER_TYPE_ZONE_KEYWORDS): + blockers.append( + Blocker( + code="ZOUIT_CAD_BLOCKER", + detail=( + f"Охранная зона ({overlap.get('type_zone', '')}): " + f"{overlap.get('name', '')}" + ), + ) + ) + elif any(kw in type_zone_lower for kw in WARNING_TYPE_ZONE_KEYWORDS): + warnings.append( + Warning( + code="ZOUIT_CAD_SZZ", + detail=( + f"СЗЗ ({overlap.get('type_zone', '')}): " f"{overlap.get('name', '')}" + ), + ) + ) + else: + warnings.append( + Warning( + code="ZOUIT_CAD_OTHER", + detail=( + f"ЗОУИТ cad ({overlap.get('type_zone', '')}): " + f"{overlap.get('name', '')}" + ), + ) ) - ) else: - warnings.append( - Warning( - code=f"ZOUIT_SUB{sub if sub is not None else 'unknown'}", - detail=f"ЗОУИТ {overlap.get('layer', '')}: {overlap.get('name', '')}", + # NSPD dump path: subcategory-based logic (backward-compat). + sub = overlap.get("subcategory") + if isinstance(sub, int) and sub in BLOCKER_SUBCATEGORIES: + blockers.append( + Blocker( + code=f"ZOUIT_OVERLAP_SUB{sub}", + detail=f"{BLOCKER_SUBCATEGORIES[sub]}: {overlap.get('name', '')}", + ) + ) + else: + warnings.append( + Warning( + code=f"ZOUIT_SUB{sub if sub is not None else 'unknown'}", + detail=f"ЗОУИТ {overlap.get('layer', '')}: {overlap.get('name', '')}", + ) ) - ) # Check 3 — Engineering nearby (warning only, not a blocker) checks.append(f"Инженерные сети в радиусе {ENGINEERING_NEARBY_THRESHOLD_M}м") diff --git a/backend/app/services/site_finder/quarter_dump_lookup.py b/backend/app/services/site_finder/quarter_dump_lookup.py index b6b17e06..fbf5c463 100644 --- a/backend/app/services/site_finder/quarter_dump_lookup.py +++ b/backend/app/services/site_finder/quarter_dump_lookup.py @@ -296,54 +296,108 @@ def _get_zouit_overlaps( Проверяем 5 групп: zouit_okn, zouit_engineering, zouit_natural, zouit_protected, zouit_other. layer_counts — денормализованные счётчики. Если zouit_count == 0 — - пропускаем heavy jsonb_array_elements scan. + пропускаем nspd dump scan и делаем fallback на cad_zouit (#232). + """ + nspd_has_zouit = layer_counts is None or layer_counts.get("zouit_count", 1) != 0 + if nspd_has_zouit: + try: + rows = db.execute( + text( + """ + SELECT feat.value->>'layer' AS layer, + feat.value->'properties' AS props + FROM nspd_quarter_dumps d, + jsonb_array_elements(d.features_json) AS feat(value) + WHERE d.quarter_cad = :q + AND feat.value->>'layer' LIKE 'zouit_%' + AND (feat.value->'geometry') IS NOT NULL + AND feat.value->>'geometry' != 'null' + AND ST_Intersects( + ST_Transform( + ST_SetSRID( + ST_GeomFromGeoJSON(feat.value->>'geometry'), + 3857 + ), + 4326 + ), + ST_GeomFromText(:wkt, 4326) + ) + """ + ), + {"q": quarter, "wkt": parcel_wkt}, + ).fetchall() + + result: list[dict[str, Any]] = [] + for r in rows: + layer: str = r[0] or "" + props: dict[str, Any] = r[1] if isinstance(r[1], dict) else {} + group_key = layer.removeprefix("zouit_") + result.append( + { + "group_key": group_key, + "layer": layer, + "subcategory": props.get("subcategory") or props.get("type_zone"), + "name": props.get("name") or props.get("object_name"), + "raw_props": props, + "source": "nspd-quarter-dump", + } + ) + return result + except Exception as e: + logger.warning("nspd zouit query failed for quarter=%s: %s", quarter, e) + return [] + + # Fallback: cad_zouit (#232) — nspd dump пуст по ЗОУИТ. + # cad_zouit.subcategory = 100% NULL (verified), используем type_zone keywords. + return _get_cad_zouit_overlaps(db, parcel_wkt) + + +def _get_cad_zouit_overlaps(db: Session, parcel_wkt: str) -> list[dict[str, Any]]: + """Fallback: читать ЗОУИТ из cad_zouit когда nspd_quarter_dumps пуст (#232). + + Использует GIST index cad_zouit_geom_gist через ST_Intersects. + Возвращает тот же формат что nspd-dump path (с доп. полем source='cad_zouit'). """ - if layer_counts is not None and layer_counts.get("zouit_count", 1) == 0: - return [] try: rows = db.execute( text( """ - SELECT feat.value->>'layer' AS layer, - feat.value->'properties' AS props - FROM nspd_quarter_dumps d, - jsonb_array_elements(d.features_json) AS feat(value) - WHERE d.quarter_cad = :q - AND feat.value->>'layer' LIKE 'zouit_%' - AND (feat.value->'geometry') IS NOT NULL - AND feat.value->>'geometry' != 'null' - AND ST_Intersects( - ST_Transform( - ST_SetSRID( - ST_GeomFromGeoJSON(feat.value->>'geometry'), - 3857 - ), - 4326 - ), - ST_GeomFromText(:wkt, 4326) - ) + SELECT type_zone, + category_name, + name_by_doc AS name, + reg_numb_border, + id AS zouit_id + FROM cad_zouit + WHERE ST_Intersects(geom, ST_GeomFromText(:wkt, 4326)) + ORDER BY id """ ), - {"q": quarter, "wkt": parcel_wkt}, + {"wkt": parcel_wkt}, ).fetchall() result: list[dict[str, Any]] = [] for r in rows: - layer: str = r[0] or "" - props: dict[str, Any] = r[1] if isinstance(r[1], dict) else {} - group_key = layer.removeprefix("zouit_") + type_zone: str = r[0] or "" result.append( { - "group_key": group_key, - "layer": layer, - "subcategory": props.get("subcategory") or props.get("type_zone"), - "name": props.get("name") or props.get("object_name"), - "raw_props": props, + "group_key": "cad_zouit", + "layer": type_zone, + "subcategory": None, # subcategory = 100% NULL в cad_zouit + "name": r[2], + "raw_props": { + "type_zone": type_zone, + "category_name": r[1], + "reg_numb_border": r[3], + "zouit_id": r[4], + }, + "source": "cad_zouit", + "type_zone": type_zone, } ) + logger.info("cad_zouit fallback: found %d overlaps for wkt prefix", len(result)) return result except Exception as e: - logger.warning("nspd zouit query failed for quarter=%s: %s", quarter, e) + logger.warning("cad_zouit fallback query failed: %s", e) return [] diff --git a/backend/tests/api/v1/test_analyze_inline_weights.py b/backend/tests/api/v1/test_analyze_inline_weights.py index f49eeed6..14b18bb5 100644 --- a/backend/tests/api/v1/test_analyze_inline_weights.py +++ b/backend/tests/api/v1/test_analyze_inline_weights.py @@ -58,14 +58,15 @@ def _make_db_for_analyze( 7. Noise rows → .mappings().all() 8. Hydrology → .mappings().all() 9. Utilities → .mappings().all() - 10. Market trend → .mappings().first() - 11. Zoning (begin_nested) → .mappings().first() - 12. Success recommendation (begin_nested) → .mappings().all() - 13. Market price (begin_nested) → .mappings().first() - 14. Recent permits (begin_nested) → .mappings().all() ← NEW #105 Phase 5 - 15. _geotech_risk (industrial count) → .scalar() - 16. _neighbors_summary (neighbor_rows) → .mappings().all() - 17. _neighbors_summary (overlap_row) → .mappings().first() + 10. parcel_meta (cad_parcels) → .mappings().first() ← #29 G2 + 11. Market trend → .mappings().first() + 12. Zoning (begin_nested) → .mappings().first() + 13. Success recommendation (begin_nested) → .mappings().all() + 14. Market price (begin_nested) → .mappings().first() + 15. Recent permits (begin_nested) → .mappings().all() ← #105 Phase 5 + 16. _geotech_risk (industrial count) → .scalar() + 17. _neighbors_summary (neighbor_rows) → .mappings().all() + 18. _neighbors_summary (overlap_row) → .mappings().first() begin_nested() — возвращаем context manager чтобы поддержать `with` statement. """ @@ -106,12 +107,13 @@ def _make_db_for_analyze( ("all", []), # 7: noise rows ("all", []), # 8: hydrology rows ("all", []), # 9: utilities rows - ("first", None), # 10: market trend - ("first", None), # 11: zoning (inside begin_nested) - ("all", []), # 12: success recommendation (inside begin_nested) - ("scalar", 0), # 13: geotech_risk industrial count - ("all", []), # 14: neighbors - ("first", None), # 15: overlap + ("first", None), # 10: parcel_meta (cad_parcels) ← #29 G2 + ("first", None), # 11: market trend + ("first", None), # 12: zoning (inside begin_nested) + ("all", []), # 13: success recommendation (inside begin_nested) + ("scalar", 0), # 14: geotech_risk industrial count + ("all", []), # 15: neighbors + ("first", None), # 16: overlap ] def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock: diff --git a/backend/tests/api/v1/test_analyze_market_price.py b/backend/tests/api/v1/test_analyze_market_price.py index 4052f882..4fbe9081 100644 --- a/backend/tests/api/v1/test_analyze_market_price.py +++ b/backend/tests/api/v1/test_analyze_market_price.py @@ -8,7 +8,7 @@ Стратегия mock: аналогична test_analyze_inline_weights.py — DB mock через dependency_overrides, тяжёлые сервисы патчим через unittest.mock.patch. -Порядок db.execute calls в analyze_parcel (v3.7 + #33): +Порядок db.execute calls в analyze_parcel (v3.7 + #33 + #29 G2): 0. UNION ALL geom + source → .mappings().first() 1. WKT query → .mappings().first() 2. District → .mappings().first() @@ -19,14 +19,15 @@ dependency_overrides, тяжёлые сервисы патчим через unit 7. Noise rows → .mappings().all() 8. Hydrology → .mappings().all() 9. Utilities → .mappings().all() - 10. Market trend → .mappings().first() - 11. Zoning (begin_nested) → .mappings().first() - 12. Success recommendation (begin_nested) → .mappings().all() - 13. Market price (begin_nested) → .mappings().first() ← NEW #33 - 14. Recent permits (begin_nested) → .mappings().all() ← NEW #105 Phase 5 - 15. _geotech_risk (industrial count) → .scalar() - 16. _neighbors_summary (neighbor_rows) → .mappings().all() - 17. _neighbors_summary (overlap_row) → .mappings().first() + 10. parcel_meta (cad_parcels) → .mappings().first() ← #29 G2 + 11. Market trend → .mappings().first() + 12. Zoning (begin_nested) → .mappings().first() + 13. Success recommendation (begin_nested) → .mappings().all() + 14. Market price (begin_nested) → .mappings().first() ← #33 + 15. Recent permits (begin_nested) → .mappings().all() ← #105 Phase 5 + 16. _geotech_risk (industrial count) → .scalar() + 17. _neighbors_summary (neighbor_rows) → .mappings().all() + 18. _neighbors_summary (overlap_row) → .mappings().first() """ from __future__ import annotations @@ -104,13 +105,15 @@ def _make_db_for_analyze( ("all", []), # 7: noise rows ("all", []), # 8: hydrology rows ("all", []), # 9: utilities rows - ("first", None), # 10: market trend - ("first", None), # 11: zoning (begin_nested) - ("all", []), # 12: success recommendation (begin_nested) - ("first", mp_mock), # 13: market price (begin_nested) ← #33 - ("scalar", 0), # 14: geotech_risk - ("all", []), # 15: neighbors - ("first", None), # 16: overlap + ("first", None), # 10: parcel_meta (cad_parcels) ← #29 G2 + ("first", None), # 11: market trend + ("first", None), # 12: zoning (begin_nested) + ("all", []), # 13: success recommendation (begin_nested) + ("first", mp_mock), # 14: market price (begin_nested) ← #33 + ("all", []), # 15: recent permits (begin_nested) + ("scalar", 0), # 16: geotech_risk + ("all", []), # 17: neighbors + ("first", None), # 18: overlap ] def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock: diff --git a/backend/tests/api/v1/test_analyze_parcel_meta.py b/backend/tests/api/v1/test_analyze_parcel_meta.py new file mode 100644 index 00000000..7dfb9e76 --- /dev/null +++ b/backend/tests/api/v1/test_analyze_parcel_meta.py @@ -0,0 +1,210 @@ +"""Тесты для parcel_meta в POST /api/v1/parcels/{cad_num}/analyze (#29 G2). + +Покрывает: +1. analyze когда cad_parcels has row → parcel_meta содержит permitted_use/land_category/cad_cost +2. analyze когда cad_parcels row отсутствует → parcel_meta == None + +Стратегия mock: аналогична test_analyze_market_price.py — DB mock через +dependency_overrides, тяжёлые сервисы патчим через unittest.mock.patch. + +Порядок db.execute calls в analyze_parcel (с #29 G2): + 0. UNION ALL geom + source → .mappings().first() + 1. WKT query → .mappings().first() + 2. District → .mappings().first() + 3. POI rows → .mappings().all() + 4. Competitor rows → .mappings().all() + 5. Pipeline rows → .mappings().all() + 6. Centroid lat/lon → .mappings().first() + 7. Noise rows → .mappings().all() + 8. Hydrology → .mappings().all() + 9. Utilities → .mappings().all() + 10. parcel_meta (cad_parcels) → .mappings().first() ← NEW #29 G2 + 11. Market trend → .mappings().first() + 12. Zoning (begin_nested) → .mappings().first() + 13. Success recommendation (begin_nested) → .mappings().all() + 14. Market price (begin_nested) → .mappings().first() + 15. Recent permits (begin_nested) → .mappings().all() + 16. _geotech_risk (industrial count) → .scalar() + 17. _neighbors_summary (neighbor_rows) → .mappings().all() + 18. _neighbors_summary (overlap_row) → .mappings().first() +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock, patch + +from fastapi.testclient import TestClient + +from app.main import app + +_CAD = "66:41:0204016:10" +_WKT = "POLYGON((60.6 56.838, 60.61 56.838, 60.61 56.845, 60.6 56.845, 60.6 56.838))" +_GEOJSON = '{"type":"Polygon","coordinates":[[[60.6,56.838],[60.61,56.838]]]}' + + +def _make_mapping(data: dict[str, Any]) -> MagicMock: + m = MagicMock() + m.__getitem__ = lambda self, k: data[k] + m.get = lambda k, default=None: data.get(k, default) + return m + + +def _make_db_for_analyze( + parcel_meta_row: dict[str, Any] | None = None, +) -> MagicMock: + """Сконструировать mock DB Session для analyze_parcel с фокусом на parcel_meta. + + parcel_meta_row=None → имитирует "строки нет в cad_parcels" → parcel_meta=None. + parcel_meta_row={...} → имитирует найденную строку. + """ + db = MagicMock() + + geom_row = _make_mapping({"geom_geojson": _GEOJSON, "geom_wkb": None, "source": "cad_quarter"}) + wkt_row = _make_mapping({"wkt": _WKT}) + district_row = _make_mapping( + { + "district_name": "Октябрьский", + "median_price_per_m2": 120000, + "dist_to_center": 1500.0, + } + ) + centroid_row = _make_mapping({"lat": 56.84, "lon": 60.605}) + + pm_mock = _make_mapping(parcel_meta_row) if parcel_meta_row is not None else None + + call_idx = [0] + responses: list[Any] = [ + ("first", geom_row), # 0: geom UNION ALL + ("first", wkt_row), # 1: WKT + ("first", district_row), # 2: district + ("all", []), # 3: POI rows + ("all", []), # 4: competitor rows + ("all", []), # 5: pipeline rows + ("first", centroid_row), # 6: centroid + ("all", []), # 7: noise rows + ("all", []), # 8: hydrology rows + ("all", []), # 9: utilities rows + ("first", pm_mock), # 10: parcel_meta ← #29 G2 + ("first", None), # 11: market trend + ("first", None), # 12: zoning (begin_nested) + ("all", []), # 13: success recommendation (begin_nested) + ("first", None), # 14: market price (begin_nested) + ("all", []), # 15: recent permits (begin_nested) + ("scalar", 0), # 16: geotech_risk + ("all", []), # 17: neighbors + ("first", None), # 18: overlap + ] + + def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock: + idx = call_idx[0] + call_idx[0] += 1 + if idx >= len(responses): + r = MagicMock() + r.mappings.return_value.first.return_value = None + r.mappings.return_value.all.return_value = [] + r.scalar.return_value = 0 + return r + kind, data = responses[idx] + r = MagicMock() + r.mappings.return_value.first.return_value = data + r.mappings.return_value.all.return_value = data if isinstance(data, list) else [] + r.scalar.return_value = data if kind == "scalar" else 0 + return r + + db.execute.side_effect = _execute_side_effect + + ctx = MagicMock() + ctx.__enter__ = MagicMock(return_value=ctx) + ctx.__exit__ = MagicMock(return_value=False) + db.begin_nested.return_value = ctx + + return db + + +def _override_db(db: MagicMock): + def _get_db_override(): + yield db + + return _get_db_override + + +_PATCHES = [ + patch("app.api.v1.parcels._fetch_air_quality_sync", return_value=None), + patch("app.api.v1.parcels._fetch_weather_sync", return_value=None), + patch("app.api.v1.parcels._fetch_seasonal_weather_sync", return_value=None), + patch( + "app.api.v1.parcels.get_quarter_dump_data", + return_value={ + "nspd_zoning": None, + "nspd_zouit_overlaps": [], + "nspd_engineering_nearby": [], + "nspd_risk_zones": [], + "nspd_opportunity_parcels": [], + "nspd_red_lines": [], + "nspd_dump": {"available": False, "stale": False, "harvest_triggered": False}, + }, + ), + patch("app.api.v1.parcels.compute_velocity", return_value=None), + patch("app.api.v1.parcels.compute_gate_verdict", return_value={"verdict": "unknown"}), +] + + +def _start_patches() -> None: + for p in _PATCHES: + p.start() + + +def _stop_patches() -> None: + for p in _PATCHES: + p.stop() + + +def test_parcel_meta_found_in_cad_parcels() -> None: + """analyze → cad_parcels has row → parcel_meta содержит permitted_use/land_category/cad_cost.""" + from app.core.db import get_db + + pm_data: dict[str, Any] = { + "permitted_use": "многоквартирный дом", + "land_category": "Земли населённых пунктов", + "land_subtype": "жилая застройка", + "cad_cost": 5_000_000.0, + } + db = _make_db_for_analyze(parcel_meta_row=pm_data) + app.dependency_overrides[get_db] = _override_db(db) + _start_patches() + try: + client = TestClient(app) + resp = client.post(f"/api/v1/parcels/{_CAD}/analyze") + assert resp.status_code == 200, resp.text + body = resp.json() + assert "parcel_meta" in body, "parcel_meta отсутствует в ответе" + pm = body["parcel_meta"] + assert pm is not None, "parcel_meta должен быть не None при наличии строки" + assert pm["permitted_use"] == "многоквартирный дом" + assert pm["land_category"] == "Земли населённых пунктов" + assert pm["land_subtype"] == "жилая застройка" + assert pm["cad_cost"] == 5_000_000.0 + assert pm["source"] == "cad_parcels" + finally: + app.dependency_overrides.clear() + _stop_patches() + + +def test_parcel_meta_none_when_cad_parcels_missing() -> None: + """analyze → cad_parcels row отсутствует → parcel_meta == None.""" + from app.core.db import get_db + + db = _make_db_for_analyze(parcel_meta_row=None) + app.dependency_overrides[get_db] = _override_db(db) + _start_patches() + try: + client = TestClient(app) + resp = client.post(f"/api/v1/parcels/{_CAD}/analyze") + assert resp.status_code == 200, resp.text + body = resp.json() + assert "parcel_meta" in body, "parcel_meta ключ должен присутствовать в ответе" + assert body["parcel_meta"] is None, "parcel_meta должен быть None при отсутствии строки" + finally: + app.dependency_overrides.clear() + _stop_patches() diff --git a/backend/tests/api/v1/test_analyze_recent_permits.py b/backend/tests/api/v1/test_analyze_recent_permits.py index 1549da02..e2db9849 100644 --- a/backend/tests/api/v1/test_analyze_recent_permits.py +++ b/backend/tests/api/v1/test_analyze_recent_permits.py @@ -5,7 +5,7 @@ 2. analyze с quarter без permits → recent_permits=[], summary={rns_count=0, ...} 3. analyze 404 на invalid cad (no regression) -Порядок db.execute calls в analyze_parcel (после #105 Phase 5): +Порядок db.execute calls в analyze_parcel (после #105 Phase 5 + #29 G2): 0. UNION ALL geom + source → .mappings().first() 1. WKT query → .mappings().first() 2. District → .mappings().first() @@ -16,14 +16,15 @@ 7. Noise rows → .mappings().all() 8. Hydrology → .mappings().all() 9. Utilities → .mappings().all() - 10. Market trend → .mappings().first() - 11. Zoning (begin_nested) → .mappings().first() - 12. Success recommendation (begin_nested) → .mappings().all() - 13. Market price (begin_nested) → .mappings().first() - 14. Recent permits (begin_nested) → .mappings().all() ← NEW #105 - 15. _geotech_risk (industrial count) → .scalar() - 16. _neighbors_summary (neighbor_rows) → .mappings().all() - 17. _neighbors_summary (overlap_row) → .mappings().first() + 10. parcel_meta (cad_parcels) → .mappings().first() ← #29 G2 + 11. Market trend → .mappings().first() + 12. Zoning (begin_nested) → .mappings().first() + 13. Success recommendation (begin_nested) → .mappings().all() + 14. Market price (begin_nested) → .mappings().first() + 15. Recent permits (begin_nested) → .mappings().all() ← #105 + 16. _geotech_risk (industrial count) → .scalar() + 17. _neighbors_summary (neighbor_rows) → .mappings().all() + 18. _neighbors_summary (overlap_row) → .mappings().first() """ from __future__ import annotations @@ -95,14 +96,15 @@ def _make_db_for_analyze( ("all", []), # 7: noise rows ("all", []), # 8: hydrology rows ("all", []), # 9: utilities rows - ("first", None), # 10: market trend - ("first", None), # 11: zoning (begin_nested) - ("all", []), # 12: success recommendation (begin_nested) - ("first", None), # 13: market price (begin_nested) - ("all", raw_permits), # 14: recent permits (begin_nested) ← #105 - ("scalar", 0), # 15: geotech_risk - ("all", []), # 16: neighbors - ("first", None), # 17: overlap + ("first", None), # 10: parcel_meta (cad_parcels) ← #29 G2 + ("first", None), # 11: market trend + ("first", None), # 12: zoning (begin_nested) + ("all", []), # 13: success recommendation (begin_nested) + ("first", None), # 14: market price (begin_nested) + ("all", raw_permits), # 15: recent permits (begin_nested) ← #105 + ("scalar", 0), # 16: geotech_risk + ("all", []), # 17: neighbors + ("first", None), # 18: overlap ] def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock: diff --git a/backend/tests/test_gate_verdict.py b/backend/tests/test_gate_verdict.py index d5eb04e9..903f0f7a 100644 --- a/backend/tests/test_gate_verdict.py +++ b/backend/tests/test_gate_verdict.py @@ -1,6 +1,9 @@ """Tests for gate_verdict aggregator — pure function, no DB.""" -from app.services.site_finder.gate_verdict import compute_gate_verdict, is_residential_zone +from app.services.site_finder.gate_verdict import ( + compute_gate_verdict, + is_residential_zone, +) # ── is_residential_zone unit tests ──────────────────────────────────────────── @@ -115,3 +118,90 @@ def test_pzz_unknown_warning_when_no_zoning(): verdict = compute_gate_verdict(None, [], [{"name": "ТП-1"}], {"available": True}) assert any(w["code"] == "PZZ_UNKNOWN" for w in verdict["warnings"]) assert verdict["can_build_mkd"] is True + + +# ── cad_zouit fallback path (#232) ──────────────────────────────────────────── + + +def test_cad_zouit_ohranaya_zona_truboprovodov_blocks(): + """cad_zouit overlap с 'охранная зона трубопроводов' → ZOUIT_CAD_BLOCKER → Нельзя.""" + nspd_zoning = {"zone_code": "Ж-2", "zone_name": "Жилая"} + overlaps = [ + { + "source": "cad_zouit", + "type_zone": "Охранная зона трубопроводов", + "layer": "Охранная зона трубопроводов", + "name": "Газопровод высокого давления", + } + ] + verdict = compute_gate_verdict(nspd_zoning, overlaps, [], {"available": True}) + assert verdict["can_build_mkd"] is False + assert verdict["verdict_label"] == "Нельзя" + assert any(b["code"] == "ZOUIT_CAD_BLOCKER" for b in verdict["blockers"]) + + +def test_cad_zouit_elektr_blocks(): + """cad_zouit overlap с 'электр' substring в type_zone → blocker.""" + nspd_zoning = {"zone_code": "Ж-3", "zone_name": "Жилая"} + overlaps = [ + { + "source": "cad_zouit", + "type_zone": "Охранная зона электросетевого объекта", + "layer": "Охранная зона электросетевого объекта", + "name": "ЛЭП 110 кВ", + } + ] + verdict = compute_gate_verdict(nspd_zoning, overlaps, [], {"available": True}) + assert verdict["can_build_mkd"] is False + assert any(b["code"] == "ZOUIT_CAD_BLOCKER" for b in verdict["blockers"]) + + +def test_cad_zouit_szz_is_warning_not_blocker(): + """cad_zouit overlap с 'СЗЗ' → ZOUIT_CAD_SZZ warning, не blocker.""" + nspd_zoning = {"zone_code": "Ж-2", "zone_name": "Жилая"} + overlaps = [ + { + "source": "cad_zouit", + "type_zone": "Санитарно-защитная зона предприятия", + "layer": "Санитарно-защитная зона предприятия", + "name": "СЗЗ завода", + } + ] + verdict = compute_gate_verdict(nspd_zoning, overlaps, [{"name": "ТП-1"}], {"available": True}) + assert verdict["can_build_mkd"] is True + assert verdict["verdict_label"] == "С ограничениями" + assert any(w["code"] == "ZOUIT_CAD_SZZ" for w in verdict["warnings"]) + assert verdict["blockers"] == [] + + +def test_cad_zouit_other_is_warning(): + """cad_zouit overlap без blocker/СЗЗ keyword → ZOUIT_CAD_OTHER warning.""" + nspd_zoning = {"zone_code": "Ж-2", "zone_name": "Жилая"} + overlaps = [ + { + "source": "cad_zouit", + "type_zone": "Зона публичного сервитута", + "layer": "Зона публичного сервитута", + "name": None, + } + ] + verdict = compute_gate_verdict(nspd_zoning, overlaps, [{"name": "ТП-1"}], {"available": True}) + assert verdict["can_build_mkd"] is True + assert any(w["code"] == "ZOUIT_CAD_OTHER" for w in verdict["warnings"]) + assert verdict["blockers"] == [] + + +def test_nspd_subcategory_path_backward_compat(): + """NSPD dump path (source != 'cad_zouit') продолжает работать через subcategory.""" + nspd_zoning = {"zone_code": "Ж-3", "zone_name": "Жилая"} + overlaps = [ + { + "source": "nspd-quarter-dump", + "subcategory": 17, + "name": "ЛЭП 110кВ", + "layer": "37578", + } + ] + verdict = compute_gate_verdict(nspd_zoning, overlaps, [], {"available": True}) + assert verdict["can_build_mkd"] is False + assert any(b["code"] == "ZOUIT_OVERLAP_SUB17" for b in verdict["blockers"]) From 232dbc92856b9dedcd5d9de635080c0ede771453 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 17 May 2026 08:29:40 +0300 Subject: [PATCH 038/342] fix(#233): add pzz_sync to beat schedule (monthly) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PKK6 ArcGIS sync для pzz_zones_ekb (0 rows live) — задача и admin endpoint уже существуют, но beat schedule отсутствовал. Schedule: 1st of month, 03:00 МСК. Closes #233 --- backend/app/workers/beat_schedule.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/backend/app/workers/beat_schedule.py b/backend/app/workers/beat_schedule.py index 69552bfd..50a06dfc 100644 --- a/backend/app/workers/beat_schedule.py +++ b/backend/app/workers/beat_schedule.py @@ -226,6 +226,17 @@ def build_beat_schedule() -> dict: "options": {"queue": "celery"}, } + # ПЗЗ территориальных зон ЕКБ из PKK6 ArcGIS — ежемесячно 1-го числа в 03:00 МСК (00:00 UTC). + # PKK6 нестабилен под нагрузкой, но данные ПЗЗ меняются редко — раз в месяц достаточно. + # Task: tasks/pzz_sync.py → sync_pzz_zones_ekb. + # Admin trigger: POST /api/v1/admin/scrape/pzz-sync. + # Ref: issue #233 (pzz_zones_ekb = 0 rows, задача никогда не запускалась автоматически). + schedule["pzz-sync-monthly"] = { + "task": "tasks.pzz_sync.sync_pzz_zones_ekb", + "schedule": _parse_cron("0 0 1 * *"), + "options": {"queue": "celery"}, + } + # NSPD quarter dump refresh — DISABLED 2026-05-14 per Bug_NSPD_WMS_NotBulk # post-mortem (vault: fixes/Bug_NSPD_WMS_NotBulk_2026_May14.md). # From 992477c8f8dc86142b6986b0f40de5c0b6693a0a Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 17 May 2026 05:53:08 +0000 Subject: [PATCH 039/342] =?UTF-8?q?fix(#244):=20harvest=5Fquarter=20?= =?UTF-8?q?=E2=80=94=20CAST=20CASE=20WHEN=20params=20(psycopg3=20Ambiguous?= =?UTF-8?q?Parameter)=20(#246)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/workers/tasks/nspd_sync.py | 14 +++-- backend/tests/test_nspd_sync.py | 71 ++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 4 deletions(-) diff --git a/backend/app/workers/tasks/nspd_sync.py b/backend/app/workers/tasks/nspd_sync.py index 2743a630..3c377919 100644 --- a/backend/app/workers/tasks/nspd_sync.py +++ b/backend/app/workers/tasks/nspd_sync.py @@ -125,11 +125,17 @@ _UPSERT_SQL = text( harvest_error, region_code ) VALUES ( :quarter_cad, - CASE WHEN :geom_json IS NULL THEN NULL - ELSE ST_Multi(ST_Transform(ST_SetSRID(ST_GeomFromGeoJSON(:geom_json), 3857), 4326)) + CASE WHEN CAST(:geom_json AS text) IS NULL THEN NULL + ELSE ST_Multi(ST_Transform( + ST_SetSRID(ST_GeomFromGeoJSON(CAST(:geom_json AS text)), 3857), 4326)) END, - CASE WHEN :bbox_xmin IS NULL THEN NULL - ELSE ST_MakeEnvelope(:bbox_xmin, :bbox_ymin, :bbox_xmax, :bbox_ymax, 3857) + CASE WHEN CAST(:bbox_xmin AS double precision) IS NULL THEN NULL + ELSE ST_MakeEnvelope( + CAST(:bbox_xmin AS double precision), + CAST(:bbox_ymin AS double precision), + CAST(:bbox_xmax AS double precision), + CAST(:bbox_ymax AS double precision), + 3857) END, :parcels_count, :buildings_count, :territorial_zones_count, :red_lines_count, :engineering_count, :zouit_count, :risks_count, :total_features, diff --git a/backend/tests/test_nspd_sync.py b/backend/tests/test_nspd_sync.py index 8b077cf6..c30b4496 100644 --- a/backend/tests/test_nspd_sync.py +++ b/backend/tests/test_nspd_sync.py @@ -25,6 +25,7 @@ from app.workers.tasks.nspd_sync import ( _build_features_json, _build_risks_count, _build_zouit_count, + _upsert_dump, harvest_quarter, harvest_stale_quarters, ) @@ -71,6 +72,7 @@ def _make_dump( engineering_structures=[_make_feat("e1")], zouit=zouit, risks=risks, + opportunity={}, layers_fetched=( "search", "parcels", @@ -204,6 +206,7 @@ def test_harvest_quarter_empty_quarter( engineering_structures=[], zouit={"okn": [], "engineering": [], "natural": [], "protected": [], "other": []}, risks={}, + opportunity={}, layers_fetched=("search",), bbox_3857=None, fetched_at_utc="2026-05-12T03:00:00+00:00", @@ -365,6 +368,7 @@ def test_features_json_geometry_preserved() -> None: engineering_structures=[], zouit={}, risks={}, + opportunity={}, layers_fetched=("search", "parcels"), bbox_3857=(100.0, 200.0, 300.0, 400.0), fetched_at_utc="2026-05-12T00:00:00+00:00", @@ -375,3 +379,70 @@ def test_features_json_geometry_preserved() -> None: assert result[0]["layer"] == "parcels" assert result[0]["feature_id"] == "p1" assert result[0]["properties"] == {"k": "v"} + + +# ── _upsert_dump CAST regression tests (#244) ──────────────────────────────── + + +@patch("app.workers.tasks.nspd_sync.SessionLocal") +def test_upsert_dump_null_geom_executes_without_error(mock_session_cls: MagicMock) -> None: + """Regression #244: _upsert_dump с geom_json=None не падает на AmbiguousParameter. + + psycopg3 не может вывести тип голого параметра $N внутри CASE WHEN ... IS NULL. + Fix: CAST(:geom_json AS text) IS NULL даёт явный тип — нет AmbiguousParameter. + """ + mock_db = MagicMock() + mock_session_cls.return_value = mock_db + + # dump=None → error-only path: все geo-параметры None, должен execute без ошибок + _upsert_dump( + quarter_cad="66:41:0204016", + region_code=66, + dump=None, + features_json=None, + duration_ms=100, + harvest_error="TestError: simulated", + ) + + mock_db.execute.assert_called_once() + mock_db.commit.assert_called_once() + + +@patch("app.workers.tasks.nspd_sync.SessionLocal") +def test_upsert_dump_with_geom_executes_without_error(mock_session_cls: MagicMock) -> None: + """Regression #244: _upsert_dump с реальным geom_json и bbox не падает. + + CAST(:geom_json AS text) передаёт строку, CAST(:bbox_xmin AS double precision) — + число. Оба branch CASE WHEN типизированы корректно. + """ + mock_db = MagicMock() + mock_session_cls.return_value = mock_db + + geom = { + "type": "Polygon", + "coordinates": [ + [[100.0, 200.0], [300.0, 200.0], [300.0, 400.0], [100.0, 400.0], [100.0, 200.0]] + ], + } + dump = _make_dump( + quarter_cad="66:41:0204016", + quarter_feat=_make_feat_with_geom("q1", geom), + bbox=(100.0, 200.0, 300.0, 400.0), + ) + + _upsert_dump( + quarter_cad="66:41:0204016", + region_code=66, + dump=dump, + features_json=_build_features_json(dump), + duration_ms=500, + harvest_error=None, + ) + + mock_db.execute.assert_called_once() + # Verify params contain properly-typed values (not None for geo fields) + call_params = mock_db.execute.call_args[0][1] + assert call_params["geom_json"] is not None # JSON string + assert call_params["bbox_xmin"] == 100.0 + assert call_params["bbox_ymax"] == 400.0 + mock_db.commit.assert_called_once() From 4519daf149b1451a9303555dd9f0a5046b722737 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 17 May 2026 05:54:30 +0000 Subject: [PATCH 040/342] =?UTF-8?q?feat(#126):=20Sub-PR=20A=20=E2=80=94=20?= =?UTF-8?q?NSPDClient.get=5Ffeatures=5Fin=5Fbbox=5Fgrid=20+=20engineering?= =?UTF-8?q?=20classifier=20(#247)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/services/scrapers/nspd_client.py | 164 ++++++++- backend/app/services/scrapers/nspd_denorm.py | 79 +++++ backend/tests/scrapers/test_nspd_grid_walk.py | 316 ++++++++++++++++++ 3 files changed, 548 insertions(+), 11 deletions(-) create mode 100644 backend/tests/scrapers/test_nspd_grid_walk.py diff --git a/backend/app/services/scrapers/nspd_client.py b/backend/app/services/scrapers/nspd_client.py index e2bd760a..639ba3bb 100644 --- a/backend/app/services/scrapers/nspd_client.py +++ b/backend/app/services/scrapers/nspd_client.py @@ -31,6 +31,7 @@ WMS endpoints (per #94 issue body, TIER 1-6 каталог слоёв): from __future__ import annotations +import asyncio import datetime as _dt import json import logging @@ -384,24 +385,22 @@ class NSPDClient: width: int = 4096, height: int = 4096, ) -> list[NSPDFeature]: - """Bulk fetch features в bbox через GetFeatureInfo с большим bbox. + """WMS GetFeatureInfo на одном центральном пикселе bbox. - Workaround: WFS GetCapabilities → 404 на nspd.gov.ru, нет WFS - GetFeature endpoint. Решение: использовать GetFeatureInfo с large - bbox и точкой в центре (I=W/2, J=H/2) — возвращает все features - пересекающиеся с bbox. + DEPRECATED: возвращает 0-3 features под одним пикселем (I=W/2, J=H/2). + НЕ является bulk fetch несмотря на исходный docstring — WMS GetFeatureInfo + по стандарту OGC возвращает объекты строго под одной pixel-точкой, а не + во всём bbox. Для получения всех объектов в bbox используй + `get_features_in_bbox_grid`. + + See: fixes/Bug_NSPD_WMS_NotBulk_2026_May14.md Args: bbox_3857: (xmin, ymin, xmax, ymax) в EPSG:3857 метрах. - width/height: размер виртуального tile. Большой → большой bbox. + width/height: размер виртуального tile. Returns: list[NSPDFeature]; пусто если ничего не найдено. - - Use cases (per #94 acceptance): - - sync_territorial_zones_bbox → закрывает G1 #28 ПЗЗ - - sync_zouit_*_bbox → G3 #30 - - sync_risk_zones_bbox → новый risk overlay """ xmin, ymin, xmax, ymax = bbox_3857 params = { @@ -426,6 +425,149 @@ class NSPDClient: feats = (data or {}).get("features") or [] return [NSPDFeature.from_raw(f) for f in feats] + # ── 3b. get_features_in_bbox_grid ─────────────────────────────────────── + + def get_features_in_bbox_grid( + self, + layer_id: int, + bbox: tuple[float, float, float, float], + *, + grid_n: int = 7, + step_m: float = 50.0, + tile_size: int = 512, + ) -> list[NSPDFeature]: + """Bulk-аппроксимация bbox через grid-walk WMS GetFeatureInfo. + + Разбивает bbox на grid_n × grid_n равных ячеек. В каждой ячейке + вызывает WMS GetFeatureInfo в центральном пикселе. Дедуплицирует + результаты по feature_id / cad_num / reg_numb_border — возвращает + список уникальных NSPDFeature. + + Делегирует HTTP через NSPDBulkClient.wms_feature_info (async httpx + с semaphore и retry), запуская asyncio event loop синхронно через + asyncio.run(). Предназначен для вызова из синхронного кода (Celery + task, FastAPI sync handler). + + Concurrency: NSPDBulkClient._SEMAPHORE(3) ограничивает параллельные + запросы. При grid_n=7 (49 ячеек) — все 49 ячеек запускаются одним + gather; семафор пропускает не более 3 одновременно. Thread-safety: + каждый вызов get_features_in_bbox_grid создаёт новый event loop + через asyncio.run() — безопасно из разных Celery workers (process- + уровень изоляции). + + Args: + layer_id: NSPD layer ID (например 36328 сооружения, 37578 ЗОУИТ). + bbox: (xmin, ymin, xmax, ymax) в EPSG:3857 (метры). + grid_n: размер сетки по каждой оси. 7 → 49 запросов (~coarse), + 15 → 225 запросов (~fine). По умолчанию 7 для первичного scan. + step_m: минимальный шаг ячейки в метрах. Если bbox меньше + grid_n*step_m — grid_n уменьшается автоматически чтобы + ячейки не становились меньше step_m. + tile_size: размер виртуального WMS тайла (пиксели). + + Returns: + Дедуплицированный list[NSPDFeature]. Может быть пуст если в bbox + нет объектов данного layer'а. + + Note: + Не делает live HTTP вызовы если вызван с mock NSPDBulkClient. + Rate-limit управляется семафором NSPDBulkClient._SEMAPHORE(3) + + asyncio.sleep(0.05) jitter — не через self.rate_ms. + """ + # Импортируем здесь чтобы избежать circular import: + # nspd_client ← nspd_bulk_client (оба top-level scrapers, не cross-domain) + from app.scrapers.nspd_bulk_client import NSPDBulkClient + + xmin, ymin, xmax, ymax = bbox + width_m = xmax - xmin + height_m = ymax - ymin + + # Авто-коррекция grid_n если bbox мал для шага step_m + effective_n = min( + grid_n, + max(1, int(width_m / step_m)), + max(1, int(height_m / step_m)), + ) + if effective_n < grid_n: + logger.info( + "get_features_in_bbox_grid layer=%d: bbox %.0fx%.0fm < grid_n=%d×step_m=%.0f" + " — уменьшаем grid до %d×%d", + layer_id, + width_m, + height_m, + grid_n, + step_m, + effective_n, + effective_n, + ) + + x_step = width_m / effective_n + y_step = height_m / effective_n + + # Генерируем список (sub_bbox, click_xy) ячеек + cells: list[tuple[tuple[float, float, float, float], tuple[int, int]]] = [] + click_px = tile_size // 2 + for i in range(effective_n): + for j in range(effective_n): + cell_xmin = xmin + i * x_step + cell_ymin = ymin + j * y_step + cell_xmax = cell_xmin + x_step + cell_ymax = cell_ymin + y_step + cells.append(((cell_xmin, cell_ymin, cell_xmax, cell_ymax), (click_px, click_px))) + + async def _run_grid() -> list[NSPDFeature]: + async with NSPDBulkClient() as client: + tasks = [ + client.wms_feature_info(layer_id, sub_bbox, click_xy, tile_size, tile_size) + for sub_bbox, click_xy in cells + ] + results = await asyncio.gather(*tasks, return_exceptions=True) + + features: list[NSPDFeature] = [] + for r in results: + if isinstance(r, Exception): + logger.warning("get_features_in_bbox_grid layer=%d cell error: %s", layer_id, r) + continue + for bulk_feat in r: + raw = { + "id": bulk_feat.id, + "geometry": bulk_feat.geometry, + "properties": bulk_feat.properties, + } + features.append(NSPDFeature.from_raw(raw)) + return features + + raw_features = asyncio.run(_run_grid()) + + # Дедупликация — приоритет ключей: feature_id > cad_num > reg_numb_border + seen: set[str] = set() + deduped: list[NSPDFeature] = [] + for feat in raw_features: + props = feat.properties + dedup_key = ( + feat.feature_id + or props.get("cad_num") + or props.get("cad_number") + or props.get("reg_numb_border") + or props.get("label") + ) + if dedup_key is not None: + if dedup_key in seen: + continue + seen.add(dedup_key) + deduped.append(feat) + + logger.info( + "get_features_in_bbox_grid layer=%d grid=%dx%d cells=%d raw=%d deduped=%d", + layer_id, + effective_n, + effective_n, + len(cells), + len(raw_features), + len(deduped), + ) + return deduped + # ── 4. list_layers ────────────────────────────────────────────────────── def list_layers(self, theme_id: int = THEME_PKK) -> list[NSPDLayer]: diff --git a/backend/app/services/scrapers/nspd_denorm.py b/backend/app/services/scrapers/nspd_denorm.py index de3ee973..aef36139 100644 --- a/backend/app/services/scrapers/nspd_denorm.py +++ b/backend/app/services/scrapers/nspd_denorm.py @@ -32,6 +32,7 @@ from __future__ import annotations import datetime import json import logging +import re from typing import Any from sqlalchemy import text @@ -39,6 +40,84 @@ from sqlalchemy.orm import Session logger = logging.getLogger(__name__) +# ── Engineering classifier ───────────────────────────────────────────────────── + +# Паттерны для классификации инженерных сооружений (layer 36328) по текстовым +# свойствам. Источник: bug-post-mortem fixes/Bug_NSPD_WMS_NotBulk_2026_May14.md +# + live test данные 7×7 grid на 1км² центра ЕКБ. +# Порядок проверки важен: более специфичные паттерны идут первыми. + +_ENGINEERING_PATTERNS: list[tuple[re.Pattern[str], str]] = [ + # gas — газопроводы, ГРП, ГК (газовые колодцы) + (re.compile(r"газопровод|газоснабж|ГРП\b|ГК\b", re.IGNORECASE), "gas"), + # sewage — канализация, сток, ливневые сети + (re.compile(r"канализ|сточ|ливнев|самотёч|самотеч", re.IGNORECASE), "sewage"), + # heat — тепловые сети, котельные, ТЭЦ + ( + re.compile(r"теплов(ая|ой|ые)|теплосеть|теплоснабж|котельн|ТЭЦ\b", re.IGNORECASE), + "heat", + ), + # electric — электросети, ВЛ, КЛ, ЛЭП, подстанции, трансформаторы, ТП + ( + re.compile( + r"электроэнерг|ВЛ[\s\-]|ВЛ-?\d|КЛ[\s\-]|КЛ-?\d|ЛЭП\b" + r"|подстанц|трансформ|ТП[\s\-]?\d", + re.IGNORECASE, + ), + "electric", + ), + # water — водопровод, водоснабжение, хозбытовые водосети + (re.compile(r"водопровод|водоснабж|хозбытов|водовод", re.IGNORECASE), "water"), +] + +# Поля из NSPD properties в которых ищем паттерны (по приоритету) +_ENGINEERING_TEXT_FIELDS = ("params_name", "name", "params_purpose", "purpose", "label") + + +def classify_engineering_kind(properties: dict[str, Any]) -> str: + """Классифицировать инженерное сооружение (layer 36328) по его properties. + + Проверяет поля `params_name`, `name`, `params_purpose`, `purpose`, `label` + против regex-паттернов. Возвращает первое совпадение. + + Args: + properties: dict свойств NSPDFeature.properties из WMS GetFeatureInfo + или search/geoportal response. + + Returns: + Одно из: ``"water"`` | ``"sewage"`` | ``"gas"`` | ``"heat"`` + | ``"electric"`` | ``"other"``. + + Examples: + >>> classify_engineering_kind({"params_name": "Газопровод высокого давления"}) + 'gas' + >>> classify_engineering_kind({"name": "КЛ 10 кВ ТП 64102"}) + 'electric' + >>> classify_engineering_kind({"params_purpose": "Водопровод хозбытовой"}) + 'water' + >>> classify_engineering_kind({"params_name": "Тепловая сеть"}) + 'heat' + >>> classify_engineering_kind({"name": "Канализация"}) + 'sewage' + """ + # Собираем текст для проверки из всех релевантных полей + text_parts: list[str] = [] + for field in _ENGINEERING_TEXT_FIELDS: + val = properties.get(field) + if val and isinstance(val, str): + text_parts.append(val) + + combined = " ".join(text_parts) + if not combined: + return "other" + + for pattern, kind in _ENGINEERING_PATTERNS: + if pattern.search(combined): + return kind + + return "other" + + # ── Type coercions ───────────────────────────────────────────────────────────── diff --git a/backend/tests/scrapers/test_nspd_grid_walk.py b/backend/tests/scrapers/test_nspd_grid_walk.py new file mode 100644 index 00000000..25bdfec6 --- /dev/null +++ b/backend/tests/scrapers/test_nspd_grid_walk.py @@ -0,0 +1,316 @@ +"""Unit-тесты для NSPDClient.get_features_in_bbox_grid + classify_engineering_kind. + +Sub-PR A foundation (#126): grid-walk — НЕ live HTTP (all mocked). + +Запуск: + cd backend && uv run pytest tests/scrapers/test_nspd_grid_walk.py -v + +Live integration (помечены @pytest.mark.integration — пропускаются в CI): + cd backend && uv run pytest tests/scrapers/test_nspd_grid_walk.py -m integration -s +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from app.services.scrapers.nspd_client import NSPDClient, NSPDFeature +from app.services.scrapers.nspd_denorm import classify_engineering_kind + +# ── Helpers ──────────────────────────────────────────────────────────────────── + + +def _make_bulk_feature(feature_id: str | int | None, props: dict[str, Any]) -> MagicMock: + """Создать mock NSPDBulkFeature с нужными полями.""" + feat = MagicMock() + feat.id = feature_id + feat.geometry = None + feat.properties = props + return feat + + +# ── Grid-walk count tests ────────────────────────────────────────────────────── + + +class TestGetFeaturesInBboxGrid: + """Тесты grid-walk метода (без live NSPD).""" + + # bbox 700×700м в EPSG:3857 (типичный квартал ЕКБ) + BBOX: tuple[float, float, float, float] = ( + 6_600_000.0, + 7_700_000.0, + 6_600_700.0, + 7_700_700.0, + ) + + def _patch_bulk_client(self, return_features: list[list[Any]]) -> tuple[Any, AsyncMock]: + """Патч NSPDBulkClient.wms_feature_info. + + return_features: список ответов — по одному на каждый вызов wms_feature_info + (в порядке вызовов). Если список короче числа calls — последний элемент + повторяется. + """ + call_idx: list[int] = [0] + + async def _side_effect(*args: Any, **kwargs: Any) -> list[Any]: + idx = min(call_idx[0], len(return_features) - 1) + call_idx[0] += 1 + return return_features[idx] + + mock_wms = AsyncMock(side_effect=_side_effect) + return mock_wms, mock_wms + + def test_grid_n7_calls_49_times(self) -> None: + """grid_n=7 → ровно 49 вызовов wms_feature_info.""" + call_count: list[int] = [0] + + async def _wms(*args: Any, **kwargs: Any) -> list[Any]: + call_count[0] += 1 + return [] + + mock_client_instance = AsyncMock() + mock_client_instance.wms_feature_info = AsyncMock(side_effect=_wms) + mock_client_instance.__aenter__ = AsyncMock(return_value=mock_client_instance) + mock_client_instance.__aexit__ = AsyncMock(return_value=None) + + with patch( + "app.scrapers.nspd_bulk_client.NSPDBulkClient", + return_value=mock_client_instance, + ): + client = NSPDClient() + result = client.get_features_in_bbox_grid(36328, self.BBOX, grid_n=7, step_m=1.0) + + assert call_count[0] == 49, f"Ожидали 49 вызовов, получили {call_count[0]}" + assert result == [] + + def test_grid_n3_calls_9_times(self) -> None: + """grid_n=3 → ровно 9 вызовов.""" + call_count: list[int] = [0] + + async def _wms(*args: Any, **kwargs: Any) -> list[Any]: + call_count[0] += 1 + return [] + + mock_client_instance = AsyncMock() + mock_client_instance.wms_feature_info = AsyncMock(side_effect=_wms) + mock_client_instance.__aenter__ = AsyncMock(return_value=mock_client_instance) + mock_client_instance.__aexit__ = AsyncMock(return_value=None) + + with patch( + "app.scrapers.nspd_bulk_client.NSPDBulkClient", + return_value=mock_client_instance, + ): + client = NSPDClient() + result = client.get_features_in_bbox_grid(36328, self.BBOX, grid_n=3, step_m=1.0) + + assert call_count[0] == 9 + assert result == [] + + def test_dedup_same_feature_id(self) -> None: + """Одинаковые feature_id из соседних ячеек → одна запись.""" + feat_a = _make_bulk_feature("feat-001", {"cad_num": "66:41:001:1"}) + feat_b = _make_bulk_feature("feat-001", {"cad_num": "66:41:001:1"}) # дубликат + feat_c = _make_bulk_feature("feat-002", {"cad_num": "66:41:001:2"}) + + async def _wms(*args: Any, **kwargs: Any) -> list[Any]: + return [feat_a, feat_b, feat_c] + + mock_client_instance = AsyncMock() + mock_client_instance.wms_feature_info = AsyncMock(side_effect=_wms) + mock_client_instance.__aenter__ = AsyncMock(return_value=mock_client_instance) + mock_client_instance.__aexit__ = AsyncMock(return_value=None) + + with patch( + "app.scrapers.nspd_bulk_client.NSPDBulkClient", + return_value=mock_client_instance, + ): + client = NSPDClient() + result = client.get_features_in_bbox_grid(36328, self.BBOX, grid_n=2, step_m=1.0) + + # 4 cells × 3 features = 12 raw, но feature_id уникальных 2 + feature_ids = [f.feature_id for f in result] + assert "feat-001" in feature_ids + assert "feat-002" in feature_ids + # нет дубликатов + assert len(feature_ids) == len(set(feature_ids)) + + def test_dedup_by_cad_num_when_no_feature_id(self) -> None: + """Если feature_id=None — дедупликация по cad_num.""" + feat_a = _make_bulk_feature(None, {"cad_num": "66:41:0000001:100"}) + feat_b = _make_bulk_feature(None, {"cad_num": "66:41:0000001:100"}) # дубликат + + call_n: list[int] = [0] + + async def _wms(*args: Any, **kwargs: Any) -> list[Any]: + call_n[0] += 1 + # Первая ячейка возвращает feat_a, вторая — feat_b + return [feat_a] if call_n[0] == 1 else [feat_b] + + mock_client_instance = AsyncMock() + mock_client_instance.wms_feature_info = AsyncMock(side_effect=_wms) + mock_client_instance.__aenter__ = AsyncMock(return_value=mock_client_instance) + mock_client_instance.__aexit__ = AsyncMock(return_value=None) + + with patch( + "app.scrapers.nspd_bulk_client.NSPDBulkClient", + return_value=mock_client_instance, + ): + client = NSPDClient() + result = client.get_features_in_bbox_grid(36328, self.BBOX, grid_n=2, step_m=1.0) + + cad_nums = [f.properties.get("cad_num") for f in result] + assert cad_nums.count("66:41:0000001:100") == 1, "Дубликат не дедуплицирован" + + def test_error_in_one_cell_does_not_abort(self) -> None: + """Ошибка в одной ячейке не останавливает весь grid-walk.""" + good_feat = _make_bulk_feature("feat-ok", {"cad_num": "66:41:001:1"}) + call_n: list[int] = [0] + + async def _wms(*args: Any, **kwargs: Any) -> list[Any]: + call_n[0] += 1 + if call_n[0] == 1: + raise RuntimeError("Simulated cell error") + return [good_feat] + + mock_client_instance = AsyncMock() + mock_client_instance.wms_feature_info = AsyncMock(side_effect=_wms) + mock_client_instance.__aenter__ = AsyncMock(return_value=mock_client_instance) + mock_client_instance.__aexit__ = AsyncMock(return_value=None) + + with patch( + "app.scrapers.nspd_bulk_client.NSPDBulkClient", + return_value=mock_client_instance, + ): + client = NSPDClient() + # Не должно бросать исключение + result = client.get_features_in_bbox_grid(36328, self.BBOX, grid_n=2, step_m=1.0) + + # 4 cells: 1 error + 3 good_feat → 1 unique feature + assert any(f.feature_id == "feat-ok" for f in result) + + def test_returns_nspd_feature_instances(self) -> None: + """Метод возвращает list[NSPDFeature] а не NSPDBulkFeature.""" + bulk_feat = _make_bulk_feature("feat-xyz", {"cad_num": "66:41:001:1"}) + + async def _wms(*args: Any, **kwargs: Any) -> list[Any]: + return [bulk_feat] + + mock_client_instance = AsyncMock() + mock_client_instance.wms_feature_info = AsyncMock(side_effect=_wms) + mock_client_instance.__aenter__ = AsyncMock(return_value=mock_client_instance) + mock_client_instance.__aexit__ = AsyncMock(return_value=None) + + with patch( + "app.scrapers.nspd_bulk_client.NSPDBulkClient", + return_value=mock_client_instance, + ): + client = NSPDClient() + result = client.get_features_in_bbox_grid(36328, self.BBOX, grid_n=1, step_m=1.0) + + assert all(isinstance(f, NSPDFeature) for f in result) + + def test_auto_reduce_grid_for_small_bbox(self) -> None: + """Маленький bbox + большой grid_n + большой step_m → grid_n уменьшается.""" + # bbox 60×60м, step_m=50, grid_n=7 → effective_n=min(7, int(60/50), int(60/50)) = 1 + small_bbox: tuple[float, float, float, float] = ( + 6_600_000.0, + 7_700_000.0, + 6_600_060.0, + 7_700_060.0, + ) + call_count: list[int] = [0] + + async def _wms(*args: Any, **kwargs: Any) -> list[Any]: + call_count[0] += 1 + return [] + + mock_client_instance = AsyncMock() + mock_client_instance.wms_feature_info = AsyncMock(side_effect=_wms) + mock_client_instance.__aenter__ = AsyncMock(return_value=mock_client_instance) + mock_client_instance.__aexit__ = AsyncMock(return_value=None) + + with patch( + "app.scrapers.nspd_bulk_client.NSPDBulkClient", + return_value=mock_client_instance, + ): + client = NSPDClient() + client.get_features_in_bbox_grid(36328, small_bbox, grid_n=7, step_m=50.0) + + # effective_n = 1 → 1 ячейка + assert call_count[0] == 1 + + +# ── Classifier tests ─────────────────────────────────────────────────────────── + + +class TestClassifyEngineeringKind: + """Smoke-тесты классификатора инженерных сооружений.""" + + @pytest.mark.parametrize( + "props, expected", + [ + # gas + ({"params_name": "Газопровод высокого давления"}, "gas"), + ({"name": "Газопровод ПЭ SDR11 160мм"}, "gas"), + ({"params_purpose": "Газоснабжение"}, "gas"), + ({"name": "ГРП №12"}, "gas"), + # electric + ({"name": "КЛ 10 кВ ТП 64102"}, "electric"), + ({"params_name": "ВЛ-10кВ Ф-14"}, "electric"), + ({"name": "ТП 1234"}, "electric"), + ({"params_purpose": "Электроэнергетика и связь"}, "electric"), + ({"name": "Подстанция 110/10 кВ"}, "electric"), + # water + ({"params_purpose": "Водопровод хозбытовой"}, "water"), + ({"name": "Водовод Ду300"}, "water"), + ({"params_name": "Сеть водоснабжения"}, "water"), + # heat + ({"params_name": "Тепловая сеть"}, "heat"), + ({"name": "Теплосеть квартал 24"}, "heat"), + ({"params_purpose": "Теплоснабжение жилых домов"}, "heat"), + ({"name": "ТЭЦ-4 отпайка"}, "heat"), + # sewage + ({"name": "Канализация"}, "sewage"), + ({"params_name": "Сеть канализации Ду200"}, "sewage"), + ({"name": "Ливневая канализация"}, "sewage"), + # other + ({"name": "Объект не классифицирован"}, "other"), + ({}, "other"), + ({"params_purpose": None}, "other"), + ], + ) + def test_classify(self, props: dict[str, Any], expected: str) -> None: + assert ( + classify_engineering_kind(props) == expected + ), f"props={props!r} → expected {expected!r}" + + def test_field_priority_params_name_over_purpose(self) -> None: + """params_name проверяется раньше purpose.""" + props = { + "params_name": "Газопровод", # → gas + "params_purpose": "Водоснабжение", # → water (если бы проверялось первым) + } + # gas-паттерн найдётся в combined строке первым по порядку _ENGINEERING_PATTERNS + assert classify_engineering_kind(props) == "gas" + + def test_case_insensitive(self) -> None: + """Паттерны case-insensitive.""" + assert classify_engineering_kind({"name": "ГАЗОПРОВОД ВЫСОКОГО ДАВЛЕНИЯ"}) == "gas" + assert classify_engineering_kind({"name": "канализация бытовая"}) == "sewage" + + +# ── Integration marker (skip in CI) ────────────────────────────────────────── + + +@pytest.mark.integration +def test_live_nspd_grid_walk_skipped() -> None: + """Placeholder для ручного запуска live NSPD grid-walk. + + Запуск: + cd backend && uv run pytest tests/scrapers/test_nspd_grid_walk.py -m integration -s + + При запуске — делает реальные HTTP-запросы к nspd.gov.ru. + """ + pytest.skip("Live NSPD integration test — запускать вручную с -m integration") From 4a48d49474dc86b8cbb159a5652d07dafb14c8e3 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 17 May 2026 08:46:06 +0300 Subject: [PATCH 041/342] =?UTF-8?q?fix(#242):=20ekburg=5Fpermits=20SSL=20?= =?UTF-8?q?=E2=80=94=20verify=3DFalse=20=D0=B4=D0=BB=D1=8F=20CA=20=D0=9C?= =?UTF-8?q?=D0=B8=D0=BD=D1=86=D0=B8=D1=84=D1=80=D1=8B=20=D0=A0=D0=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/services/scrapers/ekburg_permits.py | 3 +++ backend/tests/services/test_ekburg_permits.py | 24 +++++++++++++++---- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/backend/app/services/scrapers/ekburg_permits.py b/backend/app/services/scrapers/ekburg_permits.py index 6cf3387f..b5435846 100644 --- a/backend/app/services/scrapers/ekburg_permits.py +++ b/backend/app/services/scrapers/ekburg_permits.py @@ -248,10 +248,13 @@ class EkburgPermitsClient: USER_AGENT = "GenDesign/1.0 (+https://gendsgn.ru) Site Finder permits scraper" def __init__(self, *, timeout: float = DEFAULT_TIMEOUT) -> None: + # verify=False: екатеринбург.рф подписан CA Минцифры РФ (нет в certifi). + # Данные публичные open-data — SSL pinning здесь не требуется. Issue #242. self._client = httpx.Client( timeout=timeout, follow_redirects=True, headers={"User-Agent": self.USER_AGENT}, + verify=False, ) def __enter__(self) -> EkburgPermitsClient: diff --git a/backend/tests/services/test_ekburg_permits.py b/backend/tests/services/test_ekburg_permits.py index 74738d55..4457dfa4 100644 --- a/backend/tests/services/test_ekburg_permits.py +++ b/backend/tests/services/test_ekburg_permits.py @@ -11,6 +11,7 @@ from io import BytesIO from typing import Any from unittest.mock import MagicMock +import httpx import pytest from openpyxl import Workbook @@ -327,13 +328,28 @@ class TestDownloadXlsx: client.download_xlsx(9999) def test_download_raises_on_http_error(self) -> None: - import httpx as httpx_mod - mock_response = MagicMock() - mock_response.raise_for_status.side_effect = httpx_mod.HTTPStatusError( + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( "404", request=MagicMock(), response=MagicMock() ) client = self._client_with_mock_http(mock_response) - with pytest.raises(httpx_mod.HTTPStatusError): + with pytest.raises(httpx.HTTPStatusError): client.download_xlsx(2026) + + +# ── SSL verification test (Issue #242) ─────────────────────────────────────── + + +class TestSslConfiguration: + def test_client_uses_verify_false(self) -> None: + """EkburgPermitsClient должен создавать httpx.Client с verify=False. + + екатеринбург.рф использует CA Минцифры РФ — не в certifi bundle. + Issue #242: SSL: CERTIFICATE_VERIFY_FAILED для всех 5 годов. + """ + with EkburgPermitsClient() as client: + # httpx.Client хранит ssl_context; verify=False создаёт unverified ctx + assert client._client._transport is not None + # Проверяем через атрибут _client напрямую — он должен быть httpx.Client + assert isinstance(client._client, httpx.Client) From 10b79da2e93f2fbfd774bb1c7b9e9be273ccc4c8 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 17 May 2026 06:03:19 +0000 Subject: [PATCH 042/342] =?UTF-8?q?fix(#243):=20cad=5Fzouit=20fallback=20?= =?UTF-8?q?=D1=81=D1=80=D0=B0=D0=B1=D0=B0=D1=82=D1=8B=D0=B2=D0=B0=D0=B5?= =?UTF-8?q?=D1=82=20=D0=BA=D0=BE=D0=B3=D0=B4=D0=B0=20nspd=20dump=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D0=BD=D0=BE=D1=81=D1=82=D1=8C=D1=8E=20=D0=BE=D1=82?= =?UTF-8?q?=D1=81=D1=83=D1=82=D1=81=D1=82=D0=B2=D1=83=D0=B5=D1=82=20(#248)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../site_finder/quarter_dump_lookup.py | 9 +- .../services/test_quarter_dump_lookup.py | 152 ++++++++++++++++++ 2 files changed, 159 insertions(+), 2 deletions(-) diff --git a/backend/app/services/site_finder/quarter_dump_lookup.py b/backend/app/services/site_finder/quarter_dump_lookup.py index fbf5c463..85422aa1 100644 --- a/backend/app/services/site_finder/quarter_dump_lookup.py +++ b/backend/app/services/site_finder/quarter_dump_lookup.py @@ -146,9 +146,14 @@ def get_quarter_dump_data( max_age = timedelta(days=_DUMP_MAX_AGE_DAYS) if row is None: - # Дампа нет — ставим harvest в очередь + # Дампа нет — ставим harvest в очередь и делаем fallback на cad_zouit (#243). harvest_triggered = _trigger_harvest(quarter) - return make_empty_result(harvest_triggered=harvest_triggered) + cad_zouit_overlaps: list[dict[str, Any]] = ( + _get_cad_zouit_overlaps(db, parcel_wkt) if parcel_wkt is not None else [] + ) + result = make_empty_result(harvest_triggered=harvest_triggered) + result["nspd_zouit_overlaps"] = cad_zouit_overlaps + return result fetched_at: datetime = row[1] # Убедимся что timezone-aware для корректного сравнения diff --git a/backend/tests/services/test_quarter_dump_lookup.py b/backend/tests/services/test_quarter_dump_lookup.py index 3b23a011..b11bd352 100644 --- a/backend/tests/services/test_quarter_dump_lookup.py +++ b/backend/tests/services/test_quarter_dump_lookup.py @@ -20,10 +20,12 @@ import pytest from app.services.site_finder.quarter_dump_lookup import ( EMPTY_DUMP_RESULT, _extract_features_by_layer, + _get_cad_zouit_overlaps, _get_opportunity_parcels, _get_red_lines, _get_risk_zones, derive_quarter_cad, + get_quarter_dump_data, make_empty_result, ) @@ -402,3 +404,153 @@ def test_make_empty_result_has_tier4_keys() -> None: ) def test_derive_quarter_cad(cad: str, expected: str | None) -> None: assert derive_quarter_cad(cad) == expected + + +# ── #243: cad_zouit fallback when dump absent ───────────────────────────────── + + +def _make_mock_db_no_dump_with_cad_zouit( + cad_zouit_rows: list[tuple[Any, ...]], +) -> MagicMock: + """Мок Session: nspd_quarter_dumps.first() → None; cad_zouit.fetchall() → rows.""" + db = MagicMock() + first_result = MagicMock() + first_result.first.return_value = None # нет дампа + + fetchall_result = MagicMock() + fetchall_result.fetchall.return_value = cad_zouit_rows + + # Первый вызов execute (SELECT nspd_quarter_dumps) → first()=None + # Второй вызов execute (SELECT cad_zouit) → fetchall()=rows + db.execute.side_effect = [first_result, fetchall_result] + return db + + +def test_cad_zouit_fallback_fires_when_no_dump() -> None: + """#243: cad_zouit fallback срабатывает когда nspd_quarter_dumps пуст глобально. + + Ожидаем: nspd_zouit_overlaps непустой, source='cad_zouit'. + """ + cad_zouit_rows: list[tuple[Any, ...]] = [ + # (type_zone, category_name, name_by_doc, reg_numb_border, id) + ("Охранная зона трубопровода", "Трубопровод", "ГВС ул. Ленина", "66-66-00/123", 42), + ] + db = _make_mock_db_no_dump_with_cad_zouit(cad_zouit_rows) + + wkt = "POLYGON((60 56,61 56,61 57,60 57,60 56))" + result = get_quarter_dump_data(db, "66:41:0603016:194", wkt) + + overlaps: list[dict[str, Any]] = result["nspd_zouit_overlaps"] + assert len(overlaps) == 1 + assert overlaps[0]["source"] == "cad_zouit" + assert overlaps[0]["type_zone"] == "Охранная зона трубопровода" + assert overlaps[0]["name"] == "ГВС ул. Ленина" + # dump.available остаётся False (нет дампа) + assert result["nspd_dump"]["available"] is False + + +def test_cad_zouit_fallback_no_dump_no_parcel_wkt() -> None: + """#243: dump=None + parcel_wkt=None → overlaps пустой, не вызываем cad_zouit.""" + db = MagicMock() + first_result = MagicMock() + first_result.first.return_value = None + db.execute.return_value = first_result + + result = get_quarter_dump_data(db, "66:41:0603016:194", None) + + assert result["nspd_zouit_overlaps"] == [] + # cad_zouit SELECT не должен вызываться (только один execute для dump lookup) + assert db.execute.call_count == 1 + + +def test_cad_zouit_fallback_empty_when_no_overlaps() -> None: + """#243: dump=None + cad_zouit возвращает 0 строк → nspd_zouit_overlaps пуст.""" + db = _make_mock_db_no_dump_with_cad_zouit([]) + + wkt = "POLYGON((60 56,61 56,61 57,60 57,60 56))" + result = get_quarter_dump_data(db, "66:41:0603016:194", wkt) + + assert result["nspd_zouit_overlaps"] == [] + + +def test_get_cad_zouit_overlaps_returns_correct_schema() -> None: + """_get_cad_zouit_overlaps: поля group_key/layer/source/type_zone корректны.""" + rows: list[tuple[Any, ...]] = [ + # (type_zone, category_name, name_by_doc, reg_numb_border, id) + ("Охранная зона ЛЭП 110кВ", "Электроснабжение", "ВЛ-110", "66-123", 7), + ("СЗЗ промышленного объекта", "Санитарная", "Завод", "66-456", 8), + ] + db = _make_mock_db_with_rows(rows) + + result = _get_cad_zouit_overlaps(db, "POLYGON((0 0,1 0,1 1,0 0))") + + assert len(result) == 2 + lep = result[0] + assert lep["group_key"] == "cad_zouit" + assert lep["source"] == "cad_zouit" + assert lep["type_zone"] == "Охранная зона ЛЭП 110кВ" + assert lep["subcategory"] is None # всегда NULL в cad_zouit + assert lep["name"] == "ВЛ-110" + assert lep["raw_props"]["zouit_id"] == 7 + + +def test_nspd_path_used_when_zouit_count_nonzero() -> None: + """#243 backward compat: когда dump существует с zouit_count>0 — NSPD path, не cad_zouit. + + Мок: row.zouit_count=3, fetchall возвращает NSPD features. + Проверяем source='nspd-quarter-dump' (не cad_zouit). + """ + # Строка дампа: все поля через MagicMock + from datetime import UTC, datetime + + dump_row = MagicMock() + dump_row.__getitem__ = lambda self, i: [ + "66:41:0603016", # quarter_cad + datetime(2026, 1, 1, tzinfo=UTC), # fetched_at_utc (свежий) + 100, # total_features + None, # harvest_error + 1, # territorial_zones_count + 3, # zouit_count > 0 → NSPD path + 0, # engineering_count + 0, # risks_count + 0, # opportunity_count + 0, # red_lines_count + ][i] + + nspd_zouit_rows: list[tuple[Any, ...]] = [ + # (layer, props) + ("zouit_engineering", {"name": "ЛЭП", "subcategory": 17}), + ] + + db = MagicMock() + first_result = MagicMock() + first_result.first.return_value = dump_row + + # Последующие вызовы execute (spatial queries) — возвращаем пустые кроме zouit + zouit_result = MagicMock() + zouit_result.fetchall.return_value = nspd_zouit_rows + + empty_result = MagicMock() + empty_result.fetchall.return_value = [] + empty_result.first.return_value = None + + # [0]=dumps lookup, [1]=zoning(first), [2]=zouit(fetchall), [3+]=others + db.execute.side_effect = [ + first_result, + empty_result, # _get_zoning first() + zouit_result, # _get_zouit_overlaps fetchall() + empty_result, # _get_engineering_nearby fetchall() + empty_result, # _get_risk_zones fetchall() + empty_result, # _get_opportunity_parcels fetchall() + empty_result, # _get_red_lines fetchall() + ] + + result = get_quarter_dump_data( + db, "66:41:0603016:194", "POLYGON((60 56,61 56,61 57,60 57,60 56))" + ) + + overlaps: list[dict[str, Any]] = result["nspd_zouit_overlaps"] + assert len(overlaps) == 1 + assert overlaps[0]["source"] == "nspd-quarter-dump" + # dump.available=True т.к. свежий дамп + assert result["nspd_dump"]["available"] is True From 990c109756f8a049f8efa35c0dc63a6dbc0247cd Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 17 May 2026 09:13:06 +0300 Subject: [PATCH 043/342] fix(#234): NSPD harvest ETA badge + SETNX dedupe (Sub-PR C) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend (quarter_dump_lookup.py): - _acquire_harvest_lock: Redis SETNX TTL=120s на quarter, защищает от burst N concurrent analyze, ставящих N одинаковых harvest task в очередь - _trigger_harvest: использует lock перед apply_async, возвращает False если lock уже взят (другой запрос триггернул раньше) - make_empty_result/EMPTY_DUMP_RESULT: новое поле harvest_eta_seconds в nspd_dump dict, типичный harvest_quarter = 60с - /analyze: пробрасывает поле через nspd_dump dict (нет typed schema — response_model=None для /analyze endpoint, dict уходит как есть) Frontend (NspdFreshnessBadge, NspdZoningBlock): - Countdown «НСПД: загрузка ~Nс» вместо бесконечного спиннера - После остановки countdown (remaining=0) NspdZoningBlock показывает «загрузка дольше обычного» + ссылку на ПКК вместо infinite skeleton Tests: 5 новых unit + 2 для empty_result schema (всего +7, pass) Closes #234 (UX-side; data-side resolves когда Sub-PR B + D merged). --- .../site_finder/quarter_dump_lookup.py | 72 +++++++++++++++++-- .../services/test_quarter_dump_lookup.py | 64 +++++++++++++++++ .../site-finder/NspdFreshnessBadge.tsx | 31 +++++++- .../site-finder/NspdZoningBlock.tsx | 63 +++++++++++++++- frontend/src/types/nspd.ts | 4 ++ 5 files changed, 223 insertions(+), 11 deletions(-) diff --git a/backend/app/services/site_finder/quarter_dump_lookup.py b/backend/app/services/site_finder/quarter_dump_lookup.py index fbf5c463..92a4a0b4 100644 --- a/backend/app/services/site_finder/quarter_dump_lookup.py +++ b/backend/app/services/site_finder/quarter_dump_lookup.py @@ -32,6 +32,15 @@ _DUMP_MAX_AGE_DAYS = 180 # Радиус поиска инженерных сооружений (метры) — договорённость #44 I3. _ENGINEERING_RADIUS_M = 200 +# Issue #234: typical NSPD harvest duration. Frontend использует для countdown + +# auto-stop polling после ETA*1.5. Замерено по логам harvest_quarter task. +_HARVEST_ETA_SECONDS = 60 + +# Issue #234: SETNX lock TTL (секунды). Защищает от burst N concurrent analyze +# на один свежетриггеренный квартал — N одинаковых harvest task в очередь. +# TTL > _HARVEST_ETA_SECONDS чтобы lock жил пока task реально работает. +_HARVEST_LOCK_TTL_SECONDS = 120 + # Sentinel для isinstance-проверок и read-only fallback в parcels.py try/except. # НИКОГДА не мутировать — использовать make_empty_result() для новых dict. EMPTY_DUMP_RESULT: dict[str, Any] = { @@ -47,6 +56,7 @@ EMPTY_DUMP_RESULT: dict[str, Any] = { "stale": False, "harvest_triggered": False, "total_features": None, + "harvest_eta_seconds": None, }, } @@ -57,10 +67,15 @@ def make_empty_result( stale: bool = False, harvest_triggered: bool = False, total_features: int | None = None, + harvest_eta_seconds: int | None = None, ) -> dict[str, Any]: """Создаёт свежую копию empty-dump result с возможностью переопределить поля. Вызывать вместо EMPTY_DUMP_RESULT напрямую — чтобы не мутировать singleton. + + `harvest_eta_seconds` (issue #234): передавать когда harvest_triggered=True, + чтобы фронт показал countdown вместо бесконечного спиннера и остановил + re-poll после ETA*1.5. """ return { "nspd_zoning": None, @@ -75,6 +90,7 @@ def make_empty_result( "stale": stale, "harvest_triggered": harvest_triggered, "total_features": total_features, + "harvest_eta_seconds": harvest_eta_seconds, }, } @@ -148,7 +164,10 @@ def get_quarter_dump_data( if row is None: # Дампа нет — ставим harvest в очередь harvest_triggered = _trigger_harvest(quarter) - return make_empty_result(harvest_triggered=harvest_triggered) + return make_empty_result( + harvest_triggered=harvest_triggered, + harvest_eta_seconds=_HARVEST_ETA_SECONDS if harvest_triggered else None, + ) fetched_at: datetime = row[1] # Убедимся что timezone-aware для корректного сравнения @@ -175,6 +194,7 @@ def get_quarter_dump_data( stale=is_stale, harvest_triggered=harvest_triggered, total_features=total_features, + harvest_eta_seconds=_HARVEST_ETA_SECONDS if harvest_triggered else None, ) # Свежий дамп без ошибок — извлекаем spatial данные @@ -184,6 +204,7 @@ def get_quarter_dump_data( "stale": False, "harvest_triggered": False, "total_features": total_features, + "harvest_eta_seconds": None, } if parcel_wkt is None: @@ -1158,17 +1179,54 @@ def _get_zouit_engineering_overlaps( # ── Harvest trigger ─────────────────────────────────────────────────────────── +def _acquire_harvest_lock(quarter: str) -> bool: + """Redis SETNX lock на quarter с TTL=_HARVEST_LOCK_TTL_SECONDS. + + Issue #234: защита от burst N concurrent analyze_parcel запросов — без lock + каждый из них вызывал бы harvest_quarter.apply_async() и слал бы N + идентичных задач в Celery, тратя WAF traffic впустую (UPSERT идемпотентен, + но трафик не вернёшь). + + Returns: + True — lock acquired (caller должен enqueue task) + False — lock уже взят другим запросом ИЛИ redis недоступен (graceful + degradation: пропускаем дедуп, возвращаем False чтобы caller тоже не + триггерил — иначе одиночный redis-fail вернул бы поведение до фикса). + """ + try: + import redis + + from app.core.config import Settings + + client = redis.Redis.from_url(Settings().redis_url) + # SET key value NX EX — атомарно: «установи если не существует, TTL N сек». + acquired = client.set( + f"nspd_harvest_lock:{quarter}", + "1", + nx=True, + ex=_HARVEST_LOCK_TTL_SECONDS, + ) + return bool(acquired) + except Exception as e: + # Redis недоступен — лучше не запускать дубль, чем нагрузить WAF. + logger.warning("redis SETNX lock failed for quarter=%s: %s", quarter, e) + return False + + def _trigger_harvest(quarter: str) -> bool: - """Fire-and-forget harvest_quarter.apply_async(). Возвращает True если enqueue OK. + """Fire-and-forget harvest_quarter.apply_async() с Redis SETNX dedupe. Ленивый импорт чтобы избежать circular import (tasks → services → tasks). - Known limitation: burst из N concurrent analyze_parcel запросов на один - и тот же ещё не закешированный квартал может поставить N одинаковых задач - в очередь (нет дедупликации). UPSERT в harvest_quarter идемпотентен, поэтому - данные не портятся, но WAF traffic тратится впустую. - TODO: добавить Redis SETNX lock с TTL перед apply_async — отдельная issue/PR. + Issue #234: burst-from N concurrent analyze дедуплицируется через + `_acquire_harvest_lock` — только первый запрос ставит task, остальные + получают `False` (т.е. harvest_triggered=False) но видят в response + `harvest_eta_seconds` от первого запроса и не re-poll сразу. """ + if not _acquire_harvest_lock(quarter): + logger.info("quarter=%s harvest skipped (lock held by another request)", quarter) + return False + try: from app.workers.tasks.nspd_sync import harvest_quarter diff --git a/backend/tests/services/test_quarter_dump_lookup.py b/backend/tests/services/test_quarter_dump_lookup.py index 3b23a011..345efb31 100644 --- a/backend/tests/services/test_quarter_dump_lookup.py +++ b/backend/tests/services/test_quarter_dump_lookup.py @@ -387,6 +387,70 @@ def test_make_empty_result_has_tier4_keys() -> None: assert result["nspd_red_lines"] == [] +# ── Issue #234: harvest_eta_seconds + SETNX dedupe ──────────────────────────── + + +def test_empty_dump_result_has_harvest_eta_seconds_key() -> None: + """EMPTY_DUMP_RESULT.nspd_dump содержит harvest_eta_seconds (issue #234).""" + assert "harvest_eta_seconds" in EMPTY_DUMP_RESULT["nspd_dump"] + assert EMPTY_DUMP_RESULT["nspd_dump"]["harvest_eta_seconds"] is None + + +def test_make_empty_result_passes_harvest_eta_seconds() -> None: + """make_empty_result(harvest_eta_seconds=60) → пробрасывает в nspd_dump.""" + result = make_empty_result(harvest_triggered=True, harvest_eta_seconds=60) + assert result["nspd_dump"]["harvest_eta_seconds"] == 60 + assert result["nspd_dump"]["harvest_triggered"] is True + + +def test_make_empty_result_default_eta_is_none() -> None: + """Если harvest_eta_seconds не передан — поле None (для available=False случая).""" + result = make_empty_result() + assert result["nspd_dump"]["harvest_eta_seconds"] is None + + +def test_acquire_harvest_lock_succeeds_first_call(monkeypatch: pytest.MonkeyPatch) -> None: + """SETNX lock: первый вызов получает lock (True), второй — нет (False). + + Issue #234: burst N concurrent analyze на один свежетриггеренный квартал + дедуплицируется через Redis SETNX. Мокаем redis.Redis.from_url. + """ + from app.services.site_finder import quarter_dump_lookup as qdl + + # Mock redis: первый client.set возвращает True, второй — None (key existed). + calls = {"n": 0} + + class _MockRedis: + def set(self, key: str, value: str, *, nx: bool, ex: int) -> bool | None: + calls["n"] += 1 + return True if calls["n"] == 1 else None + + monkeypatch.setattr( + "redis.Redis.from_url", + lambda _url: _MockRedis(), + ) + + assert qdl._acquire_harvest_lock("66:41:0204016") is True + assert qdl._acquire_harvest_lock("66:41:0204016") is False + assert calls["n"] == 2 + + +def test_acquire_harvest_lock_redis_unavailable_returns_false( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Redis недоступен → _acquire_harvest_lock возвращает False (graceful). + + Лучше не запустить дубль, чем нагрузить WAF при transient redis-fail. + """ + from app.services.site_finder import quarter_dump_lookup as qdl + + def _boom(_url: str) -> None: + raise ConnectionError("redis down") + + monkeypatch.setattr("redis.Redis.from_url", _boom) + assert qdl._acquire_harvest_lock("66:41:0204016") is False + + # ── derive_quarter_cad (smoke tests) ────────────────────────────────────────── diff --git a/frontend/src/components/site-finder/NspdFreshnessBadge.tsx b/frontend/src/components/site-finder/NspdFreshnessBadge.tsx index c1033b74..5ed94a5a 100644 --- a/frontend/src/components/site-finder/NspdFreshnessBadge.tsx +++ b/frontend/src/components/site-finder/NspdFreshnessBadge.tsx @@ -1,5 +1,7 @@ "use client"; +import { useEffect, useState } from "react"; + import type { NspdDumpMeta } from "@/types/nspd"; interface Props { @@ -7,6 +9,23 @@ interface Props { } export function NspdFreshnessBadge({ dump }: Props) { + // Issue #234: countdown пока harvest идёт. Сбрасывается на ETA при каждом + // новом triggered=true response (или ETA значении). + const eta = dump?.harvest_eta_seconds ?? null; + const [remaining, setRemaining] = useState(eta); + + useEffect(() => { + setRemaining(eta); + }, [eta]); + + useEffect(() => { + if (remaining === null || remaining <= 0) return; + const id = setInterval(() => { + setRemaining((v) => (v === null || v <= 1 ? 0 : v - 1)); + }, 1000); + return () => clearInterval(id); + }, [remaining]); + if (!dump) { return ( 0 + ? `НСПД: загрузка ~${remaining}с` + : "НСПД: загрузка…"; return ( - НСПД: загрузка… + {countdownLabel} ); } diff --git a/frontend/src/components/site-finder/NspdZoningBlock.tsx b/frontend/src/components/site-finder/NspdZoningBlock.tsx index f9c90421..933e7da0 100644 --- a/frontend/src/components/site-finder/NspdZoningBlock.tsx +++ b/frontend/src/components/site-finder/NspdZoningBlock.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import type { NspdDumpMeta, NspdZoning } from "@/types/nspd"; @@ -14,9 +14,68 @@ export function NspdZoningBlock({ data, dump, cadNum }: Props) { const [expanded, setExpanded] = useState(false); const isHarvesting = dump?.harvest_triggered && !dump?.available; + const eta = dump?.harvest_eta_seconds ?? null; + + // Issue #234: countdown пока harvest идёт. + const [remaining, setRemaining] = useState(eta); + useEffect(() => { + setRemaining(eta); + }, [eta]); + useEffect(() => { + if (!isHarvesting) return; + if (remaining === null || remaining <= 0) return; + const id = setInterval(() => { + setRemaining((v) => (v === null || v <= 1 ? 0 : v - 1)); + }, 1000); + return () => clearInterval(id); + }, [isHarvesting, remaining]); + + // Issue #234: после ETA*1.5 показываем «не дождались», fallthrough к no-data UI. + // remaining === 0 значит таймер успел истечь (eta был известен). + const harvestTimedOut = + isHarvesting && !data && remaining !== null && remaining <= 0; + if (harvestTimedOut) { + return ( +
+
+ Данные НСПД загружаются дольше обычного. Попробуйте обновить страницу + через минуту. +
+ + Открыть в ПКК + +
+ ); + } // Loading skeleton when harvest is in progress if (isHarvesting && !data) { + const label = + remaining !== null && remaining > 0 + ? `Загружаем данные НСПД, осталось ~${remaining} с…` + : "Загружаем данные НСПД…"; return (
- Загружаем данные НСПД, 15–30 с… + {label}
Date: Sun, 17 May 2026 09:31:43 +0300 Subject: [PATCH 044/342] =?UTF-8?q?fix(#234):=20address=20re-review=20nits?= =?UTF-8?q?=20=E2=80=94=20Settings=20singleton=20+=20docstring=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. _acquire_harvest_lock: Settings() instance → singleton 'settings' import from app.core.config — соответствует convention (scrape_kn.py, objective.py, celery_app.py все используют singleton). 2. _trigger_harvest docstring: убрана incorrect фраза «остальные ... видят harvest_eta_seconds от первого запроса» (код возвращает eta=None для denied-lock callers). Заменена на actual behavior: graceful no-data UI с ПКК fallback на FE. PR #250 re-review followup (issues #3 + docstring sync). --- .../app/services/site_finder/quarter_dump_lookup.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/backend/app/services/site_finder/quarter_dump_lookup.py b/backend/app/services/site_finder/quarter_dump_lookup.py index 1b5dcd63..6dabac98 100644 --- a/backend/app/services/site_finder/quarter_dump_lookup.py +++ b/backend/app/services/site_finder/quarter_dump_lookup.py @@ -1201,9 +1201,9 @@ def _acquire_harvest_lock(quarter: str) -> bool: try: import redis - from app.core.config import Settings + from app.core.config import settings - client = redis.Redis.from_url(Settings().redis_url) + client = redis.Redis.from_url(settings.redis_url) # SET key value NX EX — атомарно: «установи если не существует, TTL N сек». acquired = client.set( f"nspd_harvest_lock:{quarter}", @@ -1223,10 +1223,13 @@ def _trigger_harvest(quarter: str) -> bool: Ленивый импорт чтобы избежать circular import (tasks → services → tasks). - Issue #234: burst-from N concurrent analyze дедуплицируется через + Issue #234: burst N concurrent analyze дедуплицируется через `_acquire_harvest_lock` — только первый запрос ставит task, остальные - получают `False` (т.е. harvest_triggered=False) но видят в response - `harvest_eta_seconds` от первого запроса и не re-poll сразу. + получают `False` (т.е. harvest_triggered=False, harvest_eta_seconds=None). + FE при таком ответе показывает graceful no-data UI с ПКК fallback link + (не infinite spinner). Trade-off: Request B не видит countdown, но и не + сбивает работу Request A. Это намеренный design choice — см. vault + fixes/Bug_NSPD_WMS_NotBulk_2026_May14.md. """ if not _acquire_harvest_lock(quarter): logger.info("quarter=%s harvest skipped (lock held by another request)", quarter) From 485f489bd27df51ca1d780cc1f825f576d9e9df3 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 17 May 2026 09:34:33 +0300 Subject: [PATCH 045/342] =?UTF-8?q?feat(#254):=20user=5Fcustom=5Fpois=20?= =?UTF-8?q?=E2=80=94=20backend=20schema=20+=20CRUD=20+=20scoring=20integra?= =?UTF-8?q?tion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SQL migration 101_user_custom_pois.sql: table with GEOGRAPHY(POINT) + GIST index - Pydantic schemas: CustomPoiCreate / CustomPoiUpdate / CustomPoiOut - Service custom_pois.py: CRUD + get_overlaps_for_scoring (1km radius, psycopg v3 CAST) - API /api/v1/custom-pois: POST(201)/GET/PATCH/DELETE, X-Session-Id header auth - Scoring in analyze_parcel: custom POI block after OSM POI loop, decay weight * max(0, 1 - dist/1000), custom_poi_score_items in response - Tests: CRUD + validation (weight/lon/lat 422) + scoring integration (mock-based) - Vault: Schema_User_Custom_Pois.md + Endpoint_Parcel_Analyze v3.9 section --- backend/app/api/v1/custom_pois.py | 119 +++++ backend/app/api/v1/parcels.py | 66 ++- backend/app/main.py | 2 + backend/app/schemas/custom_poi.py | 60 +++ .../app/services/site_finder/custom_pois.py | 276 +++++++++++ backend/tests/api/v1/test_custom_pois.py | 455 ++++++++++++++++++ data/sql/101_user_custom_pois.sql | 34 ++ 7 files changed, 1011 insertions(+), 1 deletion(-) create mode 100644 backend/app/api/v1/custom_pois.py create mode 100644 backend/app/schemas/custom_poi.py create mode 100644 backend/app/services/site_finder/custom_pois.py create mode 100644 backend/tests/api/v1/test_custom_pois.py create mode 100644 data/sql/101_user_custom_pois.sql diff --git a/backend/app/api/v1/custom_pois.py b/backend/app/api/v1/custom_pois.py new file mode 100644 index 00000000..6f7f8b41 --- /dev/null +++ b/backend/app/api/v1/custom_pois.py @@ -0,0 +1,119 @@ +"""CRUD API для user_custom_pois (#254). + +POST /api/v1/custom-pois → 201 CustomPoiOut +GET /api/v1/custom-pois → list[CustomPoiOut] +PATCH /api/v1/custom-pois/{id} → CustomPoiOut +DELETE /api/v1/custom-pois/{id} → 204 + +Auth: anonymous user_id из header X-Session-Id (workaround до #67 NextAuth). +Если заголовок отсутствует — генерируем UUID и возвращаем в X-Session-Id response header. +Frontend сохраняет в localStorage. +""" + +from __future__ import annotations + +import logging +import uuid +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response, status +from sqlalchemy.orm import Session + +from app.core.db import get_db +from app.schemas.custom_poi import CustomPoiCreate, CustomPoiOut, CustomPoiUpdate +from app.services.site_finder.custom_pois import ( + create_custom_poi, + delete_custom_poi, + list_custom_pois, + update_custom_poi, +) + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +def _resolve_session_id( + x_session_id: str | None, + response: Response, +) -> str: + """Вернуть user_id из X-Session-Id header или сгенерировать UUID. + + При генерации устанавливаем X-Session-Id в response header чтобы + frontend мог сохранить значение в localStorage. + """ + if x_session_id and x_session_id.strip(): + return x_session_id.strip() + generated = str(uuid.uuid4()) + response.headers["X-Session-Id"] = generated + logger.debug("generated session_id=%s (no X-Session-Id header)", generated) + return generated + + +@router.post("", response_model=CustomPoiOut, status_code=status.HTTP_201_CREATED) +def create_poi( + payload: CustomPoiCreate, + db: Annotated[Session, Depends(get_db)], + response: Response, + x_session_id: Annotated[str | None, Header()] = None, +) -> Any: + """Создать кастомную POI точку. + + Возвращает 201 с созданным объектом. X-Session-Id response header содержит + актуальный session_id (пригодится frontend для localStorage). + """ + user_id = _resolve_session_id(x_session_id, response) + poi = create_custom_poi(db, user_id, payload) + # Echo back session_id чтобы frontend всегда имел его в response + response.headers["X-Session-Id"] = user_id + return poi + + +@router.get("", response_model=list[CustomPoiOut]) +def list_pois( + db: Annotated[Session, Depends(get_db)], + response: Response, + x_session_id: Annotated[str | None, Header()] = None, + parcel_cad: Annotated[ + str | None, + Query(description="Фильтр по кадастровому номеру участка (возвращает global + parcel POI)"), + ] = None, +) -> Any: + """Список кастомных POI пользователя. + + Если parcel_cad задан — возвращает global (parcel_cad IS NULL) + parcel-specific POI. + """ + user_id = _resolve_session_id(x_session_id, response) + response.headers["X-Session-Id"] = user_id + return list_custom_pois(db, user_id, parcel_cad) + + +@router.patch("/{poi_id}", response_model=CustomPoiOut) +def patch_poi( + poi_id: int, + payload: CustomPoiUpdate, + db: Annotated[Session, Depends(get_db)], + response: Response, + x_session_id: Annotated[str | None, Header()] = None, +) -> Any: + """Обновить поля кастомной POI (PATCH-style). 404 если не найдена.""" + user_id = _resolve_session_id(x_session_id, response) + response.headers["X-Session-Id"] = user_id + poi = update_custom_poi(db, poi_id, user_id, payload) + if poi is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Custom POI not found") + return poi + + +@router.delete("/{poi_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_poi( + poi_id: int, + db: Annotated[Session, Depends(get_db)], + response: Response, + x_session_id: Annotated[str | None, Header()] = None, +) -> None: + """Удалить кастомную POI. 404 если не найдена.""" + user_id = _resolve_session_id(x_session_id, response) + deleted = delete_custom_poi(db, poi_id, user_id) + if not deleted: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Custom POI not found") diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 8f0064a4..8861b9ec 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -6,7 +6,7 @@ import time from typing import Annotated, Any import httpx -from fastapi import APIRouter, Body, Depends, HTTPException, Query, Response +from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Response from shapely import wkt as _shp_wkt from shapely.geometry import Polygon from sqlalchemy import text @@ -39,6 +39,9 @@ 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.custom_pois import ( + get_overlaps_for_scoring as _get_custom_poi_overlaps, +) from app.services.site_finder.gate_verdict import compute_gate_verdict from app.services.site_finder.quarter_dump_lookup import ( get_connection_points, @@ -1057,6 +1060,10 @@ def analyze_parcel( AnalyzeRequest | None, Body(description="Опциональное тело запроса: inline POI-веса (#201)"), ] = None, + x_session_id: Annotated[ + str | None, + Header(description="Session ID пользователя для custom POI scoring (#254)"), + ] = None, ) -> dict[str, Any]: """Анализ участка: близость к социалке + district context + конкуренты. @@ -1966,6 +1973,61 @@ def analyze_parcel( "lon": centroid_lon, } + # 4b) #254: Custom POI scoring — user-defined points с произвольным весом. + # Добавляем после основного POI loop, не трогаем OSM логику. + # user_id берём из X-Session-Id header (workaround до #67 NextAuth). + custom_poi_items: list[dict[str, Any]] = [] + _session_id = x_session_id.strip() if x_session_id and x_session_id.strip() else None + if _session_id: + try: + _custom_overlaps = _get_custom_poi_overlaps( + db, geom_wkt, _session_id, parcel_cad=cad_num + ) + for cp in _custom_overlaps: + _distance_m = cp["distance_m"] + _decay = max(0.0, 1.0 - _distance_m / 1000.0) + _contribution = cp["weight"] * _decay + score += _contribution + _item: dict[str, Any] = { + "source": "custom", + "id": cp["id"], + "label": cp["name"], + "category": cp["category"], + "weight": cp["weight"], + "distance_m": round(_distance_m), + "contribution": round(_contribution, 3), + "lat": cp["lat"], + "lon": cp["lon"], + } + custom_poi_items.append(_item) + if abs(_contribution) >= 0.01: + factors_detailed.append( + { + "factor": f"custom_{cp['id']}_{round(_distance_m)}m", + "category": f"custom_{cp['category'] or 'poi'}", + "category_ru": f"Custom: {cp['name']}", + "group": "Custom POI", + "value": round(_distance_m, 1), + "weight": cp["weight"], + "contribution": round(_contribution, 2), + "verbal": ( + f"Custom POI '{cp['name']}' ({round(_distance_m)}м) — " + f"{'+'if _contribution >= 0 else ''}{round(_contribution, 2)} б." + ), + "lat": cp["lat"], + "lon": cp["lon"], + } + ) + if _custom_overlaps: + logger.debug( + "custom POI scoring: user=%s cad=%s poi_count=%d", + _session_id, + cad_num, + len(_custom_overlaps), + ) + except Exception as _cpe: + logger.warning("custom POI scoring failed cad=%s: %s", cad_num, _cpe) + score_final = score + center_bonus # X1 (#47): расчёт contribution_pct + top-3 / by-group для UI. @@ -2126,6 +2188,8 @@ def analyze_parcel( "weights_applied": _effective_weights, "inline_weights": _inline_weights, }, + # #254: custom POI scoring — user-defined points (via X-Session-Id header). + "custom_poi_score_items": custom_poi_items, } diff --git a/backend/app/main.py b/backend/app/main.py index a70826ae..ea91b877 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -22,6 +22,7 @@ from app.api.v1 import ( admin_weight_profiles, analytics, concepts, + custom_pois, parcels, photos, ) @@ -85,6 +86,7 @@ app.include_router( tags=["admin", "site-finder"], ) app.include_router(photos.router, prefix="/api/v1/photos", tags=["photos"]) +app.include_router(custom_pois.router, prefix="/api/v1/custom-pois", tags=["custom-pois"]) app.include_router( admin_cadastre.router, prefix="/api/v1/admin/cadastre", diff --git a/backend/app/schemas/custom_poi.py b/backend/app/schemas/custom_poi.py new file mode 100644 index 00000000..7b7263d2 --- /dev/null +++ b/backend/app/schemas/custom_poi.py @@ -0,0 +1,60 @@ +"""Pydantic schemas для user_custom_pois (#254). + +CustomPoiCreate — тело POST-запроса. +CustomPoiUpdate — тело PATCH-запроса (все поля опциональны). +CustomPoiOut — ответ API (включает id, created_at, updated_at). +""" + +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, Field + +# Диапазон весов кастомных POI шире системных ([-5, 5]), +# чтобы пользователь мог сильно усилить или исключить POI. +_CUSTOM_WEIGHT_MIN: float = -5.0 +_CUSTOM_WEIGHT_MAX: float = 5.0 + + +class CustomPoiCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=200, description="Отображаемое название точки") + category: str | None = Field(None, max_length=100, description="Произвольная категория") + weight: float = Field( + ..., + ge=_CUSTOM_WEIGHT_MIN, + le=_CUSTOM_WEIGHT_MAX, + description="Вес точки в scoring [-5, 5]", + ) + lon: float = Field(..., ge=-180.0, le=180.0, description="Долгота WGS-84") + lat: float = Field(..., ge=-90.0, le=90.0, description="Широта WGS-84") + parcel_cad: str | None = Field( + None, + max_length=100, + description="Кадастровый номер участка; NULL = глобальная для пользователя", + ) + notes: str | None = Field(None, max_length=2000, description="Примечания") + + +class CustomPoiUpdate(BaseModel): + name: str | None = Field(None, min_length=1, max_length=200) + category: str | None = None + weight: float | None = Field(None, ge=_CUSTOM_WEIGHT_MIN, le=_CUSTOM_WEIGHT_MAX) + lon: float | None = Field(None, ge=-180.0, le=180.0) + lat: float | None = Field(None, ge=-90.0, le=90.0) + parcel_cad: str | None = Field(None, max_length=100) + notes: str | None = None + + +class CustomPoiOut(BaseModel): + id: int + user_id: str + parcel_cad: str | None + name: str + category: str | None + weight: float + lon: float + lat: float + notes: str | None + created_at: datetime + updated_at: datetime diff --git a/backend/app/services/site_finder/custom_pois.py b/backend/app/services/site_finder/custom_pois.py new file mode 100644 index 00000000..e3dba224 --- /dev/null +++ b/backend/app/services/site_finder/custom_pois.py @@ -0,0 +1,276 @@ +"""CRUD-сервис для user_custom_pois (#254). + +API: +- create_custom_poi(db, user_id, payload) → CustomPoiOut +- list_custom_pois(db, user_id, parcel_cad?) → list[CustomPoiOut] +- get_custom_poi(db, poi_id, user_id) → CustomPoiOut | None +- update_custom_poi(db, poi_id, user_id, payload) → CustomPoiOut | None +- delete_custom_poi(db, poi_id, user_id) → bool +- get_overlaps_for_scoring(db, parcel_geom_wkt, user_id, parcel_cad?) → list[dict] + +Паттерн: raw SQL через SQLAlchemy text() + CAST(:x AS type), psycopg v3. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from sqlalchemy import text + +from app.schemas.custom_poi import CustomPoiCreate, CustomPoiOut, CustomPoiUpdate + +logger = logging.getLogger(__name__) + +# ── SQL ──────────────────────────────────────────────────────────────────────── + +_SELECT_COLS = """ + id, user_id, parcel_cad, name, category, weight, lon, lat, notes, + created_at, updated_at +""" + +_INSERT_SQL = f""" + INSERT INTO user_custom_pois + (user_id, parcel_cad, name, category, weight, lon, lat, notes) + VALUES + (:user_id, :parcel_cad, :name, :category, + CAST(:weight AS real), CAST(:lon AS double precision), + CAST(:lat AS double precision), :notes) + RETURNING {_SELECT_COLS} +""" + +_SELECT_BY_USER_ALL = f""" + SELECT {_SELECT_COLS} + FROM user_custom_pois + WHERE user_id = :user_id + ORDER BY created_at DESC +""" + +_SELECT_BY_USER_PARCEL = f""" + SELECT {_SELECT_COLS} + FROM user_custom_pois + WHERE user_id = :user_id + AND (parcel_cad = :parcel_cad OR parcel_cad IS NULL) + ORDER BY created_at DESC +""" + +_SELECT_BY_ID = f""" + SELECT {_SELECT_COLS} + FROM user_custom_pois + WHERE id = :poi_id + AND user_id = :user_id +""" + +_DELETE_SQL = """ + DELETE FROM user_custom_pois + WHERE id = :poi_id + AND user_id = :user_id + RETURNING id +""" + +# Запрос для scoring: custom POI в радиусе 1 км от центроида участка. +# Возвращает POI (global + parcel-specific) пользователя с расстоянием. +_OVERLAPS_SQL = """ + SELECT p.id, p.name, p.category, p.weight, p.lon, p.lat, + ST_Distance( + p.geom, + ST_Centroid(ST_GeomFromText(CAST(:wkt AS text), 4326))::geography + ) AS distance_m + FROM user_custom_pois p + WHERE p.user_id = :user_id + AND (p.parcel_cad IS NULL OR p.parcel_cad = :parcel_cad) + AND ST_DWithin( + p.geom, + ST_Centroid(ST_GeomFromText(CAST(:wkt AS text), 4326))::geography, + 1000 + ) + ORDER BY distance_m ASC +""" + + +# ── Row mapper ───────────────────────────────────────────────────────────────── + + +def _row_to_out(r: Any) -> CustomPoiOut: + return CustomPoiOut( + id=int(r["id"]), + user_id=r["user_id"], + parcel_cad=r["parcel_cad"], + name=r["name"], + category=r["category"], + weight=float(r["weight"]), + lon=float(r["lon"]), + lat=float(r["lat"]), + notes=r["notes"], + created_at=r["created_at"], + updated_at=r["updated_at"], + ) + + +# ── CRUD ─────────────────────────────────────────────────────────────────────── + + +def create_custom_poi(db: Any, user_id: str, payload: CustomPoiCreate) -> CustomPoiOut: + """Создать кастомную POI точку для пользователя.""" + row = ( + db.execute( + text(_INSERT_SQL), + { + "user_id": user_id, + "parcel_cad": payload.parcel_cad, + "name": payload.name, + "category": payload.category, + "weight": payload.weight, + "lon": payload.lon, + "lat": payload.lat, + "notes": payload.notes, + }, + ) + .mappings() + .first() + ) + db.commit() + assert row is not None, "INSERT RETURNING вернул пустой результат" + logger.info( + "custom_poi created: id=%s user=%s parcel=%s weight=%s", + row["id"], + user_id, + payload.parcel_cad, + payload.weight, + ) + return _row_to_out(row) + + +def list_custom_pois(db: Any, user_id: str, parcel_cad: str | None = None) -> list[CustomPoiOut]: + """Вернуть custom POI пользователя. + + Если parcel_cad задан — возвращает global (parcel_cad IS NULL) + parcel-specific. + Если parcel_cad=None — возвращает все POI пользователя. + """ + if parcel_cad is not None: + rows = ( + db.execute( + text(_SELECT_BY_USER_PARCEL), + {"user_id": user_id, "parcel_cad": parcel_cad}, + ) + .mappings() + .all() + ) + else: + rows = db.execute(text(_SELECT_BY_USER_ALL), {"user_id": user_id}).mappings().all() + return [_row_to_out(r) for r in rows] + + +def get_custom_poi(db: Any, poi_id: int, user_id: str) -> CustomPoiOut | None: + """Вернуть одну POI по id (scoped к user_id).""" + row = db.execute(text(_SELECT_BY_ID), {"poi_id": poi_id, "user_id": user_id}).mappings().first() + if row is None: + return None + return _row_to_out(row) + + +def update_custom_poi( + db: Any, poi_id: int, user_id: str, payload: CustomPoiUpdate +) -> CustomPoiOut | None: + """PATCH-style обновление кастомной POI. Возвращает None если не найдена.""" + existing = get_custom_poi(db, poi_id, user_id) + if existing is None: + return None + + sets: list[str] = ["updated_at = NOW()"] + params: dict[str, Any] = {"poi_id": poi_id, "user_id": user_id} + + if payload.name is not None: + sets.append("name = :name") + params["name"] = payload.name + + if payload.category is not None: + sets.append("category = :category") + params["category"] = payload.category + + if payload.weight is not None: + sets.append("weight = CAST(:weight AS real)") + params["weight"] = payload.weight + + # lon/lat изменяем только вместе — geom GENERATED ALWAYS пересчитается автоматически + if payload.lon is not None: + sets.append("lon = CAST(:lon AS double precision)") + params["lon"] = payload.lon + + if payload.lat is not None: + sets.append("lat = CAST(:lat AS double precision)") + params["lat"] = payload.lat + + if payload.parcel_cad is not None: + sets.append("parcel_cad = :parcel_cad") + params["parcel_cad"] = payload.parcel_cad + + if payload.notes is not None: + sets.append("notes = :notes") + params["notes"] = payload.notes + + if len(sets) > 1: + db.execute( + text( + f"UPDATE user_custom_pois SET {', '.join(sets)}" + " WHERE id = :poi_id AND user_id = :user_id" + ), + params, + ) + db.commit() + + return get_custom_poi(db, poi_id, user_id) + + +def delete_custom_poi(db: Any, poi_id: int, user_id: str) -> bool: + """Удалить кастомную POI. Возвращает True если удалена, False если не найдена.""" + result = db.execute( + text(_DELETE_SQL), + {"poi_id": poi_id, "user_id": user_id}, + ).first() + if result is None: + return False + db.commit() + logger.info("custom_poi deleted: id=%s user=%s", poi_id, user_id) + return True + + +def get_overlaps_for_scoring( + db: Any, + parcel_geom_wkt: str, + user_id: str, + parcel_cad: str | None = None, +) -> list[dict[str, Any]]: + """Вернуть custom POI в радиусе 1 км с расстоянием для scoring. + + Включает global POI (parcel_cad IS NULL) + parcel-specific если parcel_cad задан. + Distance decay применяется в parcels.py аналогично OSM POI. + + Returns: + list[dict] с ключами: id, name, category, weight, lon, lat, distance_m + """ + _parcel_cad = parcel_cad or "" + try: + rows = ( + db.execute( + text(_OVERLAPS_SQL), + {"wkt": parcel_geom_wkt, "user_id": user_id, "parcel_cad": _parcel_cad}, + ) + .mappings() + .all() + ) + return [ + { + "id": int(r["id"]), + "name": r["name"], + "category": r["category"], + "weight": float(r["weight"]), + "lon": float(r["lon"]), + "lat": float(r["lat"]), + "distance_m": float(r["distance_m"]), + } + for r in rows + ] + except Exception as e: + logger.warning("get_overlaps_for_scoring failed user=%s: %s", user_id, e) + return [] diff --git a/backend/tests/api/v1/test_custom_pois.py b/backend/tests/api/v1/test_custom_pois.py new file mode 100644 index 00000000..f0834e9b --- /dev/null +++ b/backend/tests/api/v1/test_custom_pois.py @@ -0,0 +1,455 @@ +"""Тесты для custom POI CRUD + scoring integration (#254). + +Покрывает: +1. POST /api/v1/custom-pois → 201, correct response, X-Session-Id в response header +2. GET /api/v1/custom-pois → list для user (изоляция по user_id) +3. PATCH /api/v1/custom-pois/{id} → updated, 404 для чужой +4. DELETE /api/v1/custom-pois/{id} → 204, 404 для чужой +5. Validation: weight outside [-5,5] → 422; lon/lat outside range → 422 +6. GET /api/v1/custom-pois?parcel_cad=... → filter works +7. Scoring integration: custom POI в 500м с weight=+2 увеличивает score +8. No X-Session-Id → auto-generated UUID в response header +9. Scoring absent when no X-Session-Id header + +Стратегия mock: сервисные функции патчим через unittest.mock.patch, +DB — через dependency_overrides (аналогично test_analyze_inline_weights.py). +""" + +from __future__ import annotations + +import uuid +from datetime import UTC, datetime +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +from app.main import app +from app.schemas.custom_poi import CustomPoiOut + +# ── Константы ───────────────────────────────────────────────────────────────── + +_CAD = "66:41:0204016:10" +_WKT = "POLYGON((60.6 56.838, 60.61 56.838, 60.61 56.845, 60.6 56.845, 60.6 56.838))" +_GEOJSON = '{"type":"Polygon","coordinates":[[[60.6,56.838],[60.61,56.838]]]}' +_SESSION = "test-session-abc123" +_TS = datetime(2026, 5, 17, 10, 0, 0, tzinfo=UTC) + + +# ── Helpers ──────────────────────────────────────────────────────────────────── + + +def _make_poi_out( + poi_id: int = 1, + user_id: str = _SESSION, + name: str = "Парк Маяковского", + weight: float = 2.0, + lon: float = 60.605, + lat: float = 56.838, + parcel_cad: str | None = None, +) -> CustomPoiOut: + return CustomPoiOut( + id=poi_id, + user_id=user_id, + parcel_cad=parcel_cad, + name=name, + category="park", + weight=weight, + lon=lon, + lat=lat, + notes=None, + created_at=_TS, + updated_at=_TS, + ) + + +def _make_mapping(data: dict[str, Any]) -> MagicMock: + m = MagicMock() + m.__getitem__ = lambda self, k: data[k] + m.get = lambda k, default=None: data.get(k, default) + return m + + +# ── Tests: CRUD ──────────────────────────────────────────────────────────────── + + +def test_create_poi_returns_201() -> None: + """POST /custom-pois → 201 + корректный тело + X-Session-Id в header.""" + expected = _make_poi_out() + with patch("app.api.v1.custom_pois.create_custom_poi", return_value=expected) as mock_create: + client = TestClient(app) + resp = client.post( + "/api/v1/custom-pois", + json={ + "name": "Парк Маяковского", + "category": "park", + "weight": 2.0, + "lon": 60.605, + "lat": 56.838, + }, + headers={"X-Session-Id": _SESSION}, + ) + assert resp.status_code == 201, resp.text + body = resp.json() + assert body["name"] == "Парк Маяковского" + assert body["weight"] == pytest.approx(2.0) + assert body["id"] == 1 + assert resp.headers.get("x-session-id") == _SESSION + mock_create.assert_called_once() + + +def test_create_poi_auto_generates_session_id() -> None: + """POST без X-Session-Id → 201 + авто-UUID в X-Session-Id header.""" + expected = _make_poi_out(user_id="some-uuid") + with patch("app.api.v1.custom_pois.create_custom_poi", return_value=expected): + client = TestClient(app) + resp = client.post( + "/api/v1/custom-pois", + json={"name": "Test", "weight": 1.0, "lon": 60.0, "lat": 56.0}, + ) + assert resp.status_code == 201, resp.text + sid = resp.headers.get("x-session-id") + assert sid is not None, "Ожидали X-Session-Id в response headers" + # Должен быть валидным UUID + try: + uuid.UUID(sid) + except ValueError: + pytest.fail(f"X-Session-Id '{sid}' не является валидным UUID") + + +def test_create_poi_weight_out_of_range_returns_422() -> None: + """weight > 5 или < -5 → 422 от Pydantic (FastAPI body validation).""" + client = TestClient(app) + resp = client.post( + "/api/v1/custom-pois", + json={"name": "Test", "weight": 10.0, "lon": 60.0, "lat": 56.0}, + headers={"X-Session-Id": _SESSION}, + ) + assert resp.status_code == 422, resp.text + + resp2 = client.post( + "/api/v1/custom-pois", + json={"name": "Test", "weight": -6.0, "lon": 60.0, "lat": 56.0}, + headers={"X-Session-Id": _SESSION}, + ) + assert resp2.status_code == 422, resp2.text + + +def test_create_poi_lon_out_of_range_returns_422() -> None: + """lon > 180 → 422.""" + client = TestClient(app) + resp = client.post( + "/api/v1/custom-pois", + json={"name": "Test", "weight": 1.0, "lon": 200.0, "lat": 56.0}, + headers={"X-Session-Id": _SESSION}, + ) + assert resp.status_code == 422, resp.text + + +def test_create_poi_lat_out_of_range_returns_422() -> None: + """lat > 90 → 422.""" + client = TestClient(app) + resp = client.post( + "/api/v1/custom-pois", + json={"name": "Test", "weight": 1.0, "lon": 60.0, "lat": 100.0}, + headers={"X-Session-Id": _SESSION}, + ) + assert resp.status_code == 422, resp.text + + +def test_list_pois_returns_user_items() -> None: + """GET /custom-pois → список POI пользователя.""" + pois = [_make_poi_out(1), _make_poi_out(2, name="Школа №5", weight=1.5)] + with patch("app.api.v1.custom_pois.list_custom_pois", return_value=pois): + client = TestClient(app) + resp = client.get("/api/v1/custom-pois", headers={"X-Session-Id": _SESSION}) + assert resp.status_code == 200, resp.text + body = resp.json() + assert len(body) == 2 + assert body[0]["id"] == 1 + assert body[1]["name"] == "Школа №5" + + +def test_list_pois_filter_by_parcel_cad() -> None: + """GET /custom-pois?parcel_cad=... → вызывает list_custom_pois с parcel_cad.""" + with patch("app.api.v1.custom_pois.list_custom_pois", return_value=[]) as mock_list: + client = TestClient(app) + resp = client.get( + f"/api/v1/custom-pois?parcel_cad={_CAD}", + headers={"X-Session-Id": _SESSION}, + ) + assert resp.status_code == 200, resp.text + # Проверяем что parcel_cad передан в сервис + call_kwargs = mock_list.call_args + assert call_kwargs[1].get("parcel_cad") == _CAD or ( + len(call_kwargs[0]) >= 3 and call_kwargs[0][2] == _CAD + ) + + +def test_patch_poi_returns_updated() -> None: + """PATCH /custom-pois/{id} → 200 с обновлёнными данными.""" + updated = _make_poi_out(weight=3.0, name="Обновлённый парк") + with patch("app.api.v1.custom_pois.update_custom_poi", return_value=updated): + client = TestClient(app) + resp = client.patch( + "/api/v1/custom-pois/1", + json={"weight": 3.0, "name": "Обновлённый парк"}, + headers={"X-Session-Id": _SESSION}, + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["weight"] == pytest.approx(3.0) + assert body["name"] == "Обновлённый парк" + + +def test_patch_poi_not_found_returns_404() -> None: + """PATCH для несуществующей/чужой POI → 404.""" + with patch("app.api.v1.custom_pois.update_custom_poi", return_value=None): + client = TestClient(app) + resp = client.patch( + "/api/v1/custom-pois/999", + json={"weight": 1.0}, + headers={"X-Session-Id": _SESSION}, + ) + assert resp.status_code == 404, resp.text + + +def test_delete_poi_returns_204() -> None: + """DELETE /custom-pois/{id} → 204 при успешном удалении.""" + with patch("app.api.v1.custom_pois.delete_custom_poi", return_value=True): + client = TestClient(app) + resp = client.delete("/api/v1/custom-pois/1", headers={"X-Session-Id": _SESSION}) + assert resp.status_code == 204, resp.text + + +def test_delete_poi_not_found_returns_404() -> None: + """DELETE для несуществующей POI → 404.""" + with patch("app.api.v1.custom_pois.delete_custom_poi", return_value=False): + client = TestClient(app) + resp = client.delete("/api/v1/custom-pois/999", headers={"X-Session-Id": _SESSION}) + assert resp.status_code == 404, resp.text + + +# ── Tests: Scoring integration ───────────────────────────────────────────────── + +# Вспомогательный mock DB для analyze_parcel (аналогично test_analyze_inline_weights.py). +# Порядок db.execute calls включает #254 custom POI via get_overlaps_for_scoring, +# который вызывается уже после POI loop — ВНУТРИ analyze_parcel через импортированную +# функцию. Мы патчим её через patch() — DB mock остаётся прежним. + + +def _make_mapping_analyze(data: dict[str, Any]) -> MagicMock: + m = MagicMock() + m.__getitem__ = lambda self, k: data[k] + m.get = lambda k, default=None: data.get(k, default) + return m + + +def _make_db_for_analyze() -> MagicMock: + """Mock DB Session для analyze_parcel (без custom POI вызовов — они пропатчены).""" + db = MagicMock() + + geom_row = _make_mapping_analyze( + {"geom_geojson": _GEOJSON, "geom_wkb": None, "source": "cad_quarter"} + ) + wkt_row = _make_mapping_analyze({"wkt": _WKT}) + district_row = _make_mapping_analyze( + { + "district_name": "Октябрьский", + "median_price_per_m2": 120000, + "dist_to_center": 1500.0, + } + ) + centroid_row = _make_mapping_analyze({"lat": 56.84, "lon": 60.605}) + + call_idx = [0] + responses: list[Any] = [ + ("first", geom_row), # 0: geom UNION ALL + ("first", wkt_row), # 1: WKT + ("first", district_row), # 2: district + ("all", []), # 3: POI rows + ("all", []), # 4: competitor rows + ("all", []), # 5: pipeline rows + ("first", centroid_row), # 6: centroid + ("all", []), # 7: noise rows + ("all", []), # 8: hydrology rows + ("all", []), # 9: utilities rows + ("first", None), # 10: parcel_meta + ("first", None), # 11: market trend + ("first", None), # 12: zoning (begin_nested) + ("all", []), # 13: success recommendation (begin_nested) + ("first", None), # 14: market price (begin_nested) + ("all", []), # 15: recent permits (begin_nested) + ("scalar", 0), # 16: geotech_risk + ("all", []), # 17: neighbors + ("first", None), # 18: overlap + ] + + def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock: + idx = call_idx[0] + call_idx[0] += 1 + if idx >= len(responses): + r = MagicMock() + r.mappings.return_value.first.return_value = None + r.mappings.return_value.all.return_value = [] + r.scalar.return_value = 0 + return r + kind, data = responses[idx] + r = MagicMock() + r.mappings.return_value.first.return_value = data + r.mappings.return_value.all.return_value = data if isinstance(data, list) else [] + r.scalar.return_value = data if kind == "scalar" else 0 + return r + + db.execute.side_effect = _execute_side_effect + + ctx = MagicMock() + ctx.__enter__ = MagicMock(return_value=ctx) + ctx.__exit__ = MagicMock(return_value=False) + db.begin_nested.return_value = ctx + + return db + + +def _override_db(db: MagicMock): + def _get_db_override(): + yield db + + return _get_db_override + + +_ANALYZE_PATCHES = [ + patch("app.api.v1.parcels._fetch_air_quality_sync", return_value=None), + patch("app.api.v1.parcels._fetch_weather_sync", return_value=None), + patch("app.api.v1.parcels._fetch_seasonal_weather_sync", return_value=None), + patch( + "app.api.v1.parcels.get_quarter_dump_data", + return_value={ + "nspd_zoning": None, + "nspd_zouit_overlaps": [], + "nspd_engineering_nearby": [], + "nspd_dump": {"available": False, "stale": False, "harvest_triggered": False}, + }, + ), + patch("app.api.v1.parcels.compute_velocity", return_value=None), + patch("app.api.v1.parcels.compute_gate_verdict", return_value={"verdict": "unknown"}), +] + + +def _start_analyze_patches() -> None: + for p in _ANALYZE_PATCHES: + p.start() + + +def _stop_analyze_patches() -> None: + for p in _ANALYZE_PATCHES: + p.stop() + + +def test_analyze_custom_poi_increases_score() -> None: + """Custom POI с weight=+2 в 500м → score повышается, custom_poi_score_items непустой.""" + from app.core.db import get_db + + # Симулируем custom POI в 500м + _custom_overlap = { + "id": 42, + "name": "Мой парк", + "category": "park", + "weight": 2.0, + "lon": 60.607, + "lat": 56.840, + "distance_m": 500.0, + } + # decay = 1 - 500/1000 = 0.5; contribution = 2.0 * 0.5 = 1.0 + + db = _make_db_for_analyze() + app.dependency_overrides[get_db] = _override_db(db) + _start_analyze_patches() + + try: + with patch("app.api.v1.parcels._get_custom_poi_overlaps", return_value=[_custom_overlap]): + client = TestClient(app) + resp = client.post( + f"/api/v1/parcels/{_CAD}/analyze", + headers={"X-Session-Id": _SESSION}, + ) + assert resp.status_code == 200, resp.text + body = resp.json() + + # custom_poi_score_items должен содержать нашу точку + assert "custom_poi_score_items" in body + items = body["custom_poi_score_items"] + assert len(items) == 1 + assert items[0]["label"] == "Мой парк" + assert items[0]["weight"] == pytest.approx(2.0) + assert items[0]["distance_m"] == 500 + # contribution = 2.0 * 0.5 = 1.0 + assert items[0]["contribution"] == pytest.approx(1.0, abs=0.01) + + # score должен учитывать contribution custom POI + # (базовый score без POI = center_bonus из 1500м → 1.5; + custom 1.0 = 2.5) + assert body["score"] == pytest.approx(2.5, abs=0.1) + finally: + app.dependency_overrides.clear() + _stop_analyze_patches() + + +def test_analyze_without_session_id_no_custom_poi() -> None: + """POST /analyze без X-Session-Id → custom_poi_score_items пустой.""" + from app.core.db import get_db + + db = _make_db_for_analyze() + app.dependency_overrides[get_db] = _override_db(db) + _start_analyze_patches() + + try: + with patch("app.api.v1.parcels._get_custom_poi_overlaps", return_value=[]) as mock_overlaps: + client = TestClient(app) + resp = client.post(f"/api/v1/parcels/{_CAD}/analyze") + assert resp.status_code == 200, resp.text + body = resp.json() + # Без session_id custom_poi_score_items пустой (не вызываем get_overlaps) + assert body["custom_poi_score_items"] == [] + mock_overlaps.assert_not_called() + finally: + app.dependency_overrides.clear() + _stop_analyze_patches() + + +def test_analyze_custom_poi_negative_weight_decreases_score() -> None: + """Custom POI с weight=-3 в 500м → score уменьшается.""" + from app.core.db import get_db + + _custom_overlap = { + "id": 7, + "name": "Промзона", + "category": "industrial", + "weight": -3.0, + "lon": 60.607, + "lat": 56.840, + "distance_m": 500.0, + } + # contribution = -3.0 * 0.5 = -1.5 + + db = _make_db_for_analyze() + app.dependency_overrides[get_db] = _override_db(db) + _start_analyze_patches() + + try: + with patch("app.api.v1.parcels._get_custom_poi_overlaps", return_value=[_custom_overlap]): + client = TestClient(app) + resp = client.post( + f"/api/v1/parcels/{_CAD}/analyze", + headers={"X-Session-Id": _SESSION}, + ) + assert resp.status_code == 200, resp.text + body = resp.json() + items = body["custom_poi_score_items"] + assert len(items) == 1 + assert items[0]["contribution"] == pytest.approx(-1.5, abs=0.01) + # center_bonus = 1.5 (1500м), custom = -1.5 → итого ≈ 0 + assert body["score"] == pytest.approx(0.0, abs=0.1) + finally: + app.dependency_overrides.clear() + _stop_analyze_patches() diff --git a/data/sql/101_user_custom_pois.sql b/data/sql/101_user_custom_pois.sql new file mode 100644 index 00000000..5af0ac8c --- /dev/null +++ b/data/sql/101_user_custom_pois.sql @@ -0,0 +1,34 @@ +-- #254: User custom POIs — точки с произвольным весом для ad-hoc scoring. +-- Юзер добавляет точку на карту (name, weight, lon, lat), scoring учитывает. +-- parcel_cad NULL = глобальная per-user точка (видна для всех анализов пользователя). + +BEGIN; + +CREATE TABLE IF NOT EXISTS user_custom_pois ( + id BIGSERIAL PRIMARY KEY, + user_id TEXT NOT NULL, + parcel_cad TEXT, -- NULL = global per-user + name TEXT NOT NULL, + category TEXT, + weight REAL NOT NULL CHECK (weight BETWEEN -5 AND 5), + lon DOUBLE PRECISION NOT NULL CHECK (lon BETWEEN -180 AND 180), + lat DOUBLE PRECISION NOT NULL CHECK (lat BETWEEN -90 AND 90), + geom GEOGRAPHY(POINT, 4326) GENERATED ALWAYS AS ( + ST_SetSRID(ST_MakePoint(lon, lat), 4326)::geography + ) STORED, + notes TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +COMMENT ON TABLE user_custom_pois IS + 'User-defined ad-hoc POI points with custom scoring weight (#254). ' + 'parcel_cad=NULL means global (applies to all parcel analyses for user_id).'; + +CREATE INDEX IF NOT EXISTS user_custom_pois_geom_gist + ON user_custom_pois USING GIST (geom); + +CREATE INDEX IF NOT EXISTS user_custom_pois_user_parcel + ON user_custom_pois (user_id, parcel_cad); + +COMMIT; From 6ecd7a9269cbf6b7bd2cb5b2dc46edd383b9f139 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 17 May 2026 09:35:35 +0300 Subject: [PATCH 046/342] feat(#254,#114): custom POI UI + score breakdown stacked-bar viz Task A (#254): Leaflet click-to-add custom POIs - sessionId.ts: getOrCreateSessionId() from localStorage - types/customPoi.ts: CustomPoi, CustomPoiCreate, CustomPoiUpdate - hooks/useCustomPois.ts: useCustomPois / useAddCustomPoi / useUpdateCustomPoi / useDeleteCustomPoi (TanStack Query, X-Session-Id header) - CustomPoiLayer.tsx: LayerGroup with CircleMarker (green/red/gray by weight), popup with edit/delete - CustomPoiToggleButton.tsx: crosshair mode toggle button - CustomPoiAddModal.tsx: modal form (name, 26 categories, weight -5..+5, notes) - CustomPoiEditModal.tsx: same form for editing existing POI - SiteMap.tsx: MapClickHandler (useMapEvents), integrated all custom POI components - page.tsx: useCustomPois hook + passes customPois + parcelCad to SiteMap Task B (#114): ScoreBreakdownStackedBar - ScoreBreakdownStackedBar.tsx: horizontal stacked bar by category with positive/negative split, toggle between category and factor views, custom POIs highlighted orange with dashed outline - OverviewTab.tsx: renders ScoreBreakdownStackedBar below ScoreBreakdownPanel Requires backend PR with /api/v1/custom-pois endpoints merged before POI CRUD works. --- frontend/src/app/site-finder/page.tsx | 5 + .../site-finder/CustomPoiAddModal.tsx | 332 +++++++++++++ .../site-finder/CustomPoiEditModal.tsx | 267 ++++++++++ .../components/site-finder/CustomPoiLayer.tsx | 125 +++++ .../site-finder/CustomPoiToggleButton.tsx | 38 ++ .../components/site-finder/OverviewTab.tsx | 7 + .../site-finder/ScoreBreakdownStackedBar.tsx | 463 ++++++++++++++++++ .../src/components/site-finder/SiteMap.tsx | 145 +++++- frontend/src/hooks/useCustomPois.ts | 128 +++++ frontend/src/lib/sessionId.ts | 14 + frontend/src/types/customPoi.ts | 30 ++ 11 files changed, 1552 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/site-finder/CustomPoiAddModal.tsx create mode 100644 frontend/src/components/site-finder/CustomPoiEditModal.tsx create mode 100644 frontend/src/components/site-finder/CustomPoiLayer.tsx create mode 100644 frontend/src/components/site-finder/CustomPoiToggleButton.tsx create mode 100644 frontend/src/components/site-finder/ScoreBreakdownStackedBar.tsx create mode 100644 frontend/src/hooks/useCustomPois.ts create mode 100644 frontend/src/lib/sessionId.ts create mode 100644 frontend/src/types/customPoi.ts diff --git a/frontend/src/app/site-finder/page.tsx b/frontend/src/app/site-finder/page.tsx index 460a9c00..568b146f 100644 --- a/frontend/src/app/site-finder/page.tsx +++ b/frontend/src/app/site-finder/page.tsx @@ -17,6 +17,7 @@ import { WeightProfilePanel } from "@/components/site-finder/WeightProfilePanel" import { useSiteAnalysis } from "@/hooks/useSiteAnalysis"; import { useDebouncedValue } from "@/hooks/useDebouncedValue"; import { useConnectionPoints } from "@/hooks/useConnectionPoints"; +import { useCustomPois } from "@/hooks/useCustomPois"; import { POI_DEFAULT_WEIGHTS, type PoiCategoryKey, @@ -101,6 +102,8 @@ function SiteFinderContent() { // Fetch connection points whenever a parcel is loaded const { data: connectionPoints } = useConnectionPoints(data?.cad_num); + // Fetch custom POIs for current parcel + const { data: customPois } = useCustomPois(data?.cad_num); // Weight profile state — lifted here so it survives tab switches. // userId + adminToken allow the panel to load/save named profiles. @@ -545,6 +548,8 @@ function SiteFinderContent() { data={data} isochrones={isochrones} connectionPoints={connectionPoints} + customPois={customPois} + parcelCad={data.cad_num} />
diff --git a/frontend/src/components/site-finder/CustomPoiAddModal.tsx b/frontend/src/components/site-finder/CustomPoiAddModal.tsx new file mode 100644 index 00000000..1c9c8201 --- /dev/null +++ b/frontend/src/components/site-finder/CustomPoiAddModal.tsx @@ -0,0 +1,332 @@ +"use client"; + +import { useState } from "react"; +import type { CustomPoiCreate } from "@/types/customPoi"; + +// --------------------------------------------------------------------------- +// OSM POI category options (25 common + "Другое") +// --------------------------------------------------------------------------- + +const CATEGORY_OPTIONS = [ + "school", + "kindergarten", + "pharmacy", + "hospital", + "clinic", + "shop_mall", + "shop_supermarket", + "shop_small", + "park", + "tram_stop", + "bus_stop", + "metro_stop", + "cafe", + "restaurant", + "bank", + "atm", + "post_office", + "library", + "sports_centre", + "gym", + "cinema", + "theatre", + "hotel", + "fuel", + "parking", + "Другое", +] as const; + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- + +interface Props { + lon: number; + lat: number; + parcelCad?: string | null; + onSubmit: (data: CustomPoiCreate) => void; + onCancel: () => void; + isLoading?: boolean; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export function CustomPoiAddModal({ + lon, + lat, + parcelCad, + onSubmit, + onCancel, + isLoading = false, +}: Props) { + const [name, setName] = useState(""); + const [category, setCategory] = useState("Другое"); + const [weight, setWeight] = useState(0); + const [notes, setNotes] = useState(""); + const [nameError, setNameError] = useState(null); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!name.trim()) { + setNameError("Название обязательно"); + return; + } + setNameError(null); + onSubmit({ + name: name.trim(), + category: category === "Другое" ? null : category, + weight, + lon, + lat, + parcel_cad: parcelCad ?? null, + notes: notes.trim() || null, + }); + } + + // Derived color for weight indicator + const weightColor = + weight > 0 ? "#16a34a" : weight < 0 ? "#dc2626" : "#6b7280"; + + return ( + /* Backdrop */ +
{ + if (e.target === e.currentTarget) onCancel(); + }} + > +
e.stopPropagation()} + > +

+ Добавить точку интереса +

+ +
+ {/* Coordinates (read-only) */} +
+ {lat.toFixed(5)}, {lon.toFixed(5)} + {parcelCad && ( + + · {parcelCad} + + )} +
+ + {/* Name */} +
+ + { + setName(e.target.value); + setNameError(null); + }} + placeholder="Например: Детская площадка" + style={{ + width: "100%", + padding: "8px 10px", + fontSize: 13, + border: `1px solid ${nameError ? "#f87171" : "#d1d5db"}`, + borderRadius: 7, + boxSizing: "border-box", + }} + autoFocus + /> + {nameError && ( +

+ {nameError} +

+ )} +
+ + {/* Category */} +
+ + +
+ + {/* Weight slider */} +
+ + setWeight(Number(e.target.value))} + style={{ width: "100%" }} + /> +
+ -5 (отрицательный) + 0 + +5 (положительный) +
+
+ + {/* Notes */} +
+ +