diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 20edea76..aef70f76 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -1849,6 +1849,73 @@ 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. + _quarter_prefix = ":".join(_cad_parts[:3]) if len(_cad_parts) >= 3 else cad_num + recent_permits: list[dict[str, Any]] = [] + permits_summary: dict[str, Any] = { + "rns_count": 0, + "rve_count": 0, + "rns_total_area_sqm": 0.0, + "rve_total_units": 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, units_count + 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_prefix}, + ) + .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, + "units_count": r["units_count"], + } + for r in permits_rows + ] + rns_list = [p for p in recent_permits if p["permit_type"] == "RNS"] + rve_list = [p for p in recent_permits if p["permit_type"] == "RVE"] + 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(rve_list), + "rns_total_area_sqm": sum(p["total_area_sqm"] or 0.0 for p in rns_list), + "rve_total_units": sum(p["units_count"] or 0 for p in rve_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 +2053,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..66f91718 100644 --- a/backend/app/schemas/parcel.py +++ b/backend/app/schemas/parcel.py @@ -3,6 +3,34 @@ 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 + units_count: int | None + + +class PermitsSummary(BaseModel): + """Агрегированная сводка по разрешениям в квартале.""" + + rns_count: int + rve_count: int + rns_total_area_sqm: float + rve_total_units: int + 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..fba71625 --- /dev/null +++ b/backend/tests/api/v1/test_analyze_recent_permits.py @@ -0,0 +1,283 @@ +"""Тесты для 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, + "units_count": None, + } + 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, + "units_count": 320, + } + + 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["units_count"] == 320 + assert rve["issue_date"] == "2025-03-10" + + 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 summary["rve_total_units"] == 320 + 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 summary["rve_total_units"] == 0 + 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()