This commit is contained in:
parent
efa66a9f68
commit
27a0957bb5
3 changed files with 336 additions and 0 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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) ────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
263
backend/tests/api/v1/test_analyze_market_price.py
Normal file
263
backend/tests/api/v1/test_analyze_market_price.py
Normal file
|
|
@ -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()
|
||||
Loading…
Add table
Reference in a new issue