feat(permits): Celery beat monthly + analyze recent_permits (#105 Phase 4+5) #213
6 changed files with 388 additions and 6 deletions
|
|
@ -1849,6 +1849,68 @@ 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.
|
||||
recent_permits: list[dict[str, Any]] = []
|
||||
permits_summary: dict[str, Any] = {
|
||||
"rns_count": 0,
|
||||
"rve_count": 0,
|
||||
"rns_total_area_sqm": 0.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
|
||||
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_cad},
|
||||
)
|
||||
.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,
|
||||
}
|
||||
for r in permits_rows
|
||||
]
|
||||
rns_list = [p for p in recent_permits if p["permit_type"] == "RNS"]
|
||||
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([p for p in recent_permits if p["permit_type"] == "RVE"]),
|
||||
"rns_total_area_sqm": sum(p["total_area_sqm"] or 0.0 for p in rns_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 +2048,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),
|
||||
|
|
|
|||
|
|
@ -3,6 +3,32 @@ 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
|
||||
|
||||
|
||||
class PermitsSummary(BaseModel):
|
||||
"""Агрегированная сводка по разрешениям в квартале."""
|
||||
|
||||
rns_count: int
|
||||
rve_count: int
|
||||
rns_total_area_sqm: float
|
||||
by_developer: list[dict[str, Any]]
|
||||
|
||||
|
||||
# ── Connection points schemas (issue #115) ────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
#
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
281
backend/tests/api/v1/test_analyze_recent_permits.py
Normal file
281
backend/tests/api/v1/test_analyze_recent_permits.py
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
"""Тесты для 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,
|
||||
}
|
||||
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,
|
||||
}
|
||||
|
||||
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["issue_date"] == "2025-03-10"
|
||||
assert "units_count" not in rve
|
||||
|
||||
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 "rve_total_units" not in summary
|
||||
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 "rve_total_units" not in summary
|
||||
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()
|
||||
Loading…
Add table
Reference in a new issue