feat(permits): Celery beat monthly + analyze recent_permits (#105 Phase 4+5) (#213)
Some checks failed
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Failing after 16s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 3m44s
Deploy / deploy (push) Has been skipped

This commit is contained in:
lekss361 2026-05-16 12:23:52 +00:00
parent e450546178
commit 46203ab365
6 changed files with 388 additions and 6 deletions

View file

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

View file

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

View file

@ -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).
#

View file

@ -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.
"""

View file

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

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