feat(site-finder): integrate nspd_quarter_dumps cache в analyze_parcel (#94 Sprint 1.1 FINAL) #116

Merged
lekss361 merged 3 commits from feat/analyze-uses-quarter-dump into main 2026-05-13 06:14:19 +00:00
3 changed files with 21 additions and 17 deletions
Showing only changes of commit 7ca4d35e88 - Show all commits

View file

@ -22,7 +22,10 @@ from app.services.site_finder.cadastre_fetch import (
from app.services.site_finder.cadastre_fetch import (
fetch_status as _fetch_status,
)
from app.services.site_finder.quarter_dump_lookup import EMPTY_DUMP_RESULT, get_quarter_dump_data
from app.services.site_finder.quarter_dump_lookup import (
get_quarter_dump_data,
make_empty_result,
)
logger = logging.getLogger(__name__)
@ -1535,7 +1538,8 @@ def analyze_parcel(
nspd_dump_data = get_quarter_dump_data(db, cad_num, geom_wkt)
except Exception as e:
logger.warning("nspd quarter dump lookup failed for %s: %s", cad_num, e)
nspd_dump_data = EMPTY_DUMP_RESULT
# Independent dict per request — never mutate module singleton.
nspd_dump_data = make_empty_result()
# 10) Market trend — динамика цен ДДУ в радиусе 3 км за 6 vs предыдущие 6 месяцев
market_trend: dict[str, Any] | None = None

View file

@ -30,7 +30,7 @@ _DUMP_MAX_AGE_DAYS = 180
_ENGINEERING_RADIUS_M = 200
# Sentinel для isinstance-проверок и read-only fallback в parcels.py try/except.
# НИКОГДА не мутировать — использовать _make_empty_result() для новых dict.
# НИКОГДА не мутировать — использовать make_empty_result() для новых dict.
EMPTY_DUMP_RESULT: dict[str, Any] = {
"nspd_zoning": None,
"nspd_zouit_overlaps": [],
@ -45,7 +45,7 @@ EMPTY_DUMP_RESULT: dict[str, Any] = {
}
def _make_empty_result(
def make_empty_result(
*,
fetched_at_utc: str | None = None,
stale: bool = False,
@ -109,7 +109,7 @@ def get_quarter_dump_data(
quarter = derive_quarter_cad(cad_num)
if quarter is None:
logger.warning("quarter_dump_lookup: cannot derive quarter from cad=%s", cad_num)
return _make_empty_result()
return make_empty_result()
# Читаем строку дампа из БД. Денормализованные счётчики слоёв используются
# для early-exit в spatial helpers (M2 mitigation).
@ -136,7 +136,7 @@ def get_quarter_dump_data(
if row is None:
# Дампа нет — ставим harvest в очередь
harvest_triggered = _trigger_harvest(quarter)
return _make_empty_result(harvest_triggered=harvest_triggered)
return make_empty_result(harvest_triggered=harvest_triggered)
fetched_at: datetime = row[1]
# Убедимся что timezone-aware для корректного сравнения
@ -155,7 +155,7 @@ def get_quarter_dump_data(
# Устаревший или с ошибкой — триггерим повторный harvest
if is_stale or has_error:
harvest_triggered = _trigger_harvest(quarter)
return _make_empty_result(
return make_empty_result(
fetched_at_utc=fetched_at.isoformat(),
stale=is_stale,
harvest_triggered=harvest_triggered,

View file

@ -7,7 +7,7 @@ Mock-based — без реальной БД и Celery. Проверяет:
- get_quarter_dump_data: свежий дамп с territorial_zones nspd_zoning populated
- get_quarter_dump_data: свежий дамп с 2 zouit nspd_zouit_overlaps length=2
- get_quarter_dump_data: parcel_wkt=None metadata only, no spatial queries
- _make_empty_result: mutation-safety каждый вызов возвращает независимый dict
- make_empty_result: mutation-safety каждый вызов возвращает независимый dict
- early-exit: layer_counts == 0 spatial query не вызывается
"""
@ -19,9 +19,9 @@ from unittest.mock import MagicMock, patch
from app.services.site_finder.quarter_dump_lookup import (
EMPTY_DUMP_RESULT,
_make_empty_result,
derive_quarter_cad,
get_quarter_dump_data,
make_empty_result,
)
# ── derive_quarter_cad ────────────────────────────────────────────────────────
@ -303,17 +303,17 @@ def test_get_quarter_dump_includes_nspd_fields() -> None:
assert "total_features" in dump
# ── _make_empty_result — mutation safety ─────────────────────────────────────
# ── make_empty_result — mutation safety ─────────────────────────────────────
def test_make_empty_result_returns_independent_copies() -> None:
"""Каждый вызов _make_empty_result() возвращает независимый dict.
def testmake_empty_result_returns_independent_copies() -> None:
"""Каждый вызов make_empty_result() возвращает независимый dict.
Мутация nspd_dump в одном результате не должна влиять на другой
и на модульный sentinel EMPTY_DUMP_RESULT.
"""
a = _make_empty_result()
b = _make_empty_result()
a = make_empty_result()
b = make_empty_result()
# Вложенный dict — независимый объект
assert a["nspd_dump"] is not b["nspd_dump"]
@ -329,9 +329,9 @@ def test_make_empty_result_returns_independent_copies() -> None:
assert EMPTY_DUMP_RESULT["nspd_zouit_overlaps"] == []
def test_make_empty_result_overrides() -> None:
"""_make_empty_result принимает override-аргументы."""
r = _make_empty_result(harvest_triggered=True, stale=True, total_features=42)
def testmake_empty_result_overrides() -> None:
"""make_empty_result принимает override-аргументы."""
r = make_empty_result(harvest_triggered=True, stale=True, total_features=42)
assert r["nspd_dump"]["harvest_triggered"] is True
assert r["nspd_dump"]["stale"] is True
assert r["nspd_dump"]["total_features"] == 42