From 27a0957bb5e2fba478cb3a25b9463bfab4c0bd45 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 16 May 2026 11:28:16 +0000 Subject: [PATCH] =?UTF-8?q?feat(analyze):=20add=20market=5Fprice=20=D0=B8?= =?UTF-8?q?=D0=B7=20mv=5Fquarter=5Fprice=5Fper=5Fm2=20(#33=20PR=20B)=20(#2?= =?UTF-8?q?10)?= 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()