feat(analyze): add market_price из mv_quarter_price_per_m2 (#33 PR B)
POST /analyze returns market_price из mv_quarter_price_per_m2 (52K quarters).
Fields: p25/median/p75/mean/median_{6,12,24}m/deals_count/last_deal_date.
source='quarter_mv' либо 'no_data'. district.median_price_per_m2 kept для backward-compat.
MarketPrice BaseModel в schemas. 3 mock tests.
This commit is contained in:
parent
efa66a9f68
commit
614c951998
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)
|
logger.warning("success_recommendation query failed for %s: %s", cad_num, e)
|
||||||
success_recommendation = None
|
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
|
# 10d) Geology stub — реальные данные требуют ВСЕГЕИ-200/1000 шейпы в PostGIS
|
||||||
karpinsky_url = (
|
karpinsky_url = (
|
||||||
f"https://www.karpinskyinstitute.ru/ru/gisatlas/web-gisatlas/"
|
f"https://www.karpinskyinstitute.ru/ru/gisatlas/web-gisatlas/"
|
||||||
|
|
@ -1935,6 +1984,8 @@ def analyze_parcel(
|
||||||
# P2 (#46) — соседи-здания + overlap check
|
# P2 (#46) — соседи-здания + overlap check
|
||||||
"neighbors_summary": _neighbors_summary(db, geom_wkt, cad_num),
|
"neighbors_summary": _neighbors_summary(db, geom_wkt, cad_num),
|
||||||
"market_trend": market_trend,
|
"market_trend": market_trend,
|
||||||
|
# #33 D2: квартальная ценовая статистика из mv_quarter_price_per_m2
|
||||||
|
"market_price": market_price,
|
||||||
"zoning": zoning,
|
"zoning": zoning,
|
||||||
"success_recommendation": success_recommendation,
|
"success_recommendation": success_recommendation,
|
||||||
"isochrones_available": bool(settings.openrouteservice_api_key),
|
"isochrones_available": bool(settings.openrouteservice_api_key),
|
||||||
|
|
|
||||||
|
|
@ -215,6 +215,28 @@ class BestLayoutsResponse(BaseModel):
|
||||||
data_quality: LayoutDataQuality
|
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) ────────────────────────────────────
|
# ── 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