feat(analyze): add market_price из mv_quarter_price_per_m2 (#33 PR B) #210

Merged
lekss361 merged 1 commit from feat/33-analyze-quarter-price into main 2026-05-16 11:28:17 +00:00
3 changed files with 336 additions and 0 deletions

View file

@ -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),

View file

@ -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 мес., 30K800K руб/м², 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) ────────────────────────────────────

View 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()