feat(parcels): persist analysis runs + real last_analysis_date (#994)
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m35s
Deploy / build-worker (push) Successful in 2m31s
Deploy / deploy (push) Successful in 1m42s

Persist each successful POST /analyze into analysis_runs (migration 127):
best-effort SAVEPOINT-wrapped INSERT in a thin analysis_runs repository, then
explicit db.commit() (get_db has no commit-on-success; SAVEPOINT RELEASE alone
does not persist — without the commit the row rolls back on request teardown,
a silent no-op caught in code review). A persist failure never breaks the
response or poisons the session. Result serialized via jsonable_encoder→
json.dumps (handles embedded Pydantic models/dates); confidence/status
normalized to satisfy the 127 CHECK constraints (unknown→NULL/'complete').

Populate by-bbox last_analysis_date from v_analysis_runs_latest via a single
batch query (no N+1), replacing the #307 placeholder. The read is best-effort
wrapped too — a view-missing deploy window or future drift must not 500 the
map (falls back to last_analysis_date=None).

Additive only — analyze response shape unchanged. Tests: by-bbox suite patches
latest_run_dates (mock-db returns same rows for any query) + new test asserts
ISO last_analysis_date from a latest run. Analyze mock-suite unaffected
(graceful side_effect overflow + best-effort persist absorb the extra INSERT).
Incidental: ruff-format fixed one pre-existing f-string spacing (line ~2391)
the format hook flags on touch.

Closes #994. Refs #961.
This commit is contained in:
Light1YT 2026-06-03 17:36:01 +05:00
parent 0f194de069
commit 72eae3bc9b
4 changed files with 310 additions and 7 deletions

View file

@ -31,6 +31,11 @@ from app.schemas.parcel import (
RedLine, RedLine,
RiskZone, RiskZone,
) )
from app.services.analysis_runs.repository import (
ANALYZE_SCHEMA_VERSION,
latest_run_dates,
persist_analysis_run,
)
from app.services.exporters.layout_tz_pdf import render_layout_tz_pdf from app.services.exporters.layout_tz_pdf import render_layout_tz_pdf
from app.services.exporters.snapshot_pdf import generate_snapshot_pdf from app.services.exporters.snapshot_pdf import generate_snapshot_pdf
from app.services.site_finder.best_layouts import get_best_layouts from app.services.site_finder.best_layouts import get_best_layouts
@ -1023,7 +1028,8 @@ async def get_parcels_by_bbox(
Использует GIST-индекс на cad_parcels.geom (ST_Intersects). Использует GIST-индекс на cad_parcels.geom (ST_Intersects).
Если передан user_id добавляет статус из parcel_user_status (overlay). Если передан user_id добавляет статус из parcel_user_status (overlay).
last_analysis_date placeholder до реализации B2 auth (#307). last_analysis_date реальная дата последнего анализа из v_analysis_runs_latest
(#994, миграция 127): один batch-запрос на весь bbox, участки без рана → None.
""" """
if min_lat >= max_lat or min_lon >= max_lon: if min_lat >= max_lat or min_lon >= max_lon:
raise HTTPException(status_code=400, detail="Некорректный bbox: min >= max") raise HTTPException(status_code=400, detail="Некорректный bbox: min >= max")
@ -1081,6 +1087,16 @@ async def get_parcels_by_bbox(
.all() .all()
) )
# #994: дата последнего анализа на участок — один batch-запрос на весь bbox
# к v_analysis_runs_latest (НЕ N+1). Участки без рана отсутствуют в dict → None.
# Best-effort: провал чтения (напр. view 127 ещё не применён в deploy-окне, или
# будущий drift) НЕ должен ронять карту → fallback {} (last_analysis_date=None).
try:
last_analysis = latest_run_dates(db, [row["cad_num"] for row in rows])
except Exception:
logger.exception("by-bbox: latest_run_dates failed → last_analysis_date=None для всех")
last_analysis = {}
markers: list[ParcelMapMarker] = [ markers: list[ParcelMapMarker] = [
ParcelMapMarker( ParcelMapMarker(
cad_num=row["cad_num"], cad_num=row["cad_num"],
@ -1089,7 +1105,11 @@ async def get_parcels_by_bbox(
area_m2=float(row["area_m2"]) if row["area_m2"] is not None else None, area_m2=float(row["area_m2"]) if row["area_m2"] is not None else None,
land_category=row["land_category"], land_category=row["land_category"],
status=row["user_status"] if user_id else None, status=row["user_status"] if user_id else None,
last_analysis_date=None, # placeholder, real after B2 auth (#307) last_analysis_date=(
last_analysis[row["cad_num"]].date().isoformat()
if row["cad_num"] in last_analysis
else None
),
) )
for row in rows for row in rows
] ]
@ -1165,6 +1185,13 @@ def analyze_parcel(
str | None, str | None,
Header(description="Session ID пользователя для custom POI scoring (#254)"), Header(description="Session ID пользователя для custom POI scoring (#254)"),
] = None, ] = None,
x_authenticated_user: Annotated[
str | None,
Header(
alias="X-Authenticated-User",
description="Идентичность из Caddy basic_auth → created_by рана (#994, nullable)",
),
] = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Анализ участка: близость к социалке + district context + конкуренты. """Анализ участка: близость к социалке + district context + конкуренты.
@ -2364,7 +2391,7 @@ def analyze_parcel(
"contribution": round(_contribution, 2), "contribution": round(_contribution, 2),
"verbal": ( "verbal": (
f"Custom POI '{cp['name']}' ({round(_distance_m)}м) — " f"Custom POI '{cp['name']}' ({round(_distance_m)}м) — "
f"{'+'if _contribution >= 0 else ''}{round(_contribution, 2)} б." f"{'+' if _contribution >= 0 else ''}{round(_contribution, 2)} б."
), ),
"lat": cp["lat"], "lat": cp["lat"],
"lon": cp["lon"], "lon": cp["lon"],
@ -2502,7 +2529,7 @@ def analyze_parcel(
"top_sellers": _top_sellers_list, "top_sellers": _top_sellers_list,
} }
return { result_payload: dict[str, Any] = {
"cad_num": cad_num, "cad_num": cad_num,
"source": source, "source": source,
"geom_geojson": json.loads(geom_geojson) if geom_geojson else None, "geom_geojson": json.loads(geom_geojson) if geom_geojson else None,
@ -2619,6 +2646,39 @@ def analyze_parcel(
"risks": risks_block, "risks": risks_block,
} }
# #994 (961-C3, ТЗ §22): persist завершённого рана в analysis_runs.
# Best-effort — repository обёрнут в SAVEPOINT + try/except, провал НЕ меняет
# форму/успех ответа (frontend зависит от него) и не отравляет outer-сессию.
# district денормализуем из result["district"]["district_name"] (для фильтрации
# без JSON-разбора); confidence — отчётный уровень high/medium/low из
# confidence_label (нормализуется под CHECK в repository). schema_version —
# ANALYZE_SCHEMA_VERSION: результат здесь — inline-dict analyze, НЕ
# SiteFinderReport.as_dict() (у того свой _SCHEMA_VERSION "1.0").
_district_name = (
result_payload["district"]["district_name"]
if isinstance(result_payload.get("district"), dict)
else None
)
persist_analysis_run(
db,
cad_num=cad_num,
result=result_payload,
params={
"profile_id": profile_id,
"profile_user_id": profile_user_id,
"inline_weights": _inline_weights,
"weights_source": _weights_source,
"x_session_id": _session_id,
},
district=_district_name,
confidence=confidence_info["label"],
status="complete",
schema_version=ANALYZE_SCHEMA_VERSION,
created_by=x_authenticated_user,
)
return result_payload
@router.get( @router.get(
"/{cad_num}/connection-points", "/{cad_num}/connection-points",

View file

@ -0,0 +1,5 @@
"""analysis_runs — durable persist/read слой анализ-ранов Site Finder (#994, ТЗ §22).
Тонкий data-access поверх таблицы `analysis_runs` + view `v_analysis_runs_latest`
(миграция 127, #993). Endpoint'ы (parcels.py) держим тонкими — вся SQL-логика здесь.
"""

View file

@ -0,0 +1,195 @@
"""Data-access для analysis_runs (#994, 961-C3, ТЗ §22, EPIC 17 «GG-форсайт»).
Тонкий repository поверх таблицы `analysis_runs` + view `v_analysis_runs_latest`
(миграция 127 / #993). Держит endpoint `analyze_parcel` тонким: persist одного
завершённого рана + чтение «даты последнего анализа» (powers by-bbox карту).
Контракт миграции 127 (см. data/sql/127_analysis_runs.sql):
- result/params/segment JSONB; пишем через CAST(:x AS jsonb) + json.dumps
(psycopg v3: НИКОГДА :x::type backend.md). result может содержать Pydantic-
модели (RiskZone/OpportunityParcel/RedLine) прогоняем через jsonable_encoder
перед json.dumps, иначе TypeError на сериализации.
- CHECK'и: status ∈ {complete,partial,error}; confidence ∈ {high,medium,low} ИЛИ
NULL. Чтобы НИКОГДА не словить IntegrityError на write валидируем обе колонки
локально (`_normalize_confidence` / `_normalize_status`): неизвестное NULL/дефолт,
не сырое значение. Плохой ран не должен превращать успешный analyze в 500.
"""
from __future__ import annotations
import json
import logging
from datetime import datetime
from typing import Any
from fastapi.encoders import jsonable_encoder
from sqlalchemy import Row, text
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
# Версия схемы рана, когда result — это dict эндпоинта analyze (НЕ SiteFinderReport).
# SiteFinderReport.as_dict() несёт собственный _SCHEMA_VERSION ("1.0"); для inline-
# результата analyze, у которого своей версии формы нет, фиксируем ОТДЕЛЬНУЮ строку
# с префиксом, чтобы re-open мог различить два источника (#992 формализует контракт).
ANALYZE_SCHEMA_VERSION = "analyze-1.0"
# CHECK-зеркала миграции 127 — единственный источник допустимых значений на write.
_ALLOWED_CONFIDENCE: frozenset[str] = frozenset({"high", "medium", "low"})
_ALLOWED_STATUS: frozenset[str] = frozenset({"complete", "partial", "error"})
_DEFAULT_STATUS = "complete"
def _normalize_confidence(confidence: str | None) -> str | None:
"""Привести confidence к CHECK-допустимому high|medium|low или None.
Пустая строка / неизвестное значение None (а НЕ '' это нарушило бы CHECK).
"""
if confidence is None:
return None
value = confidence.strip().lower()
return value if value in _ALLOWED_CONFIDENCE else None
def _normalize_status(status: str | None) -> str:
"""Привести status к CHECK-допустимому complete|partial|error (дефолт complete)."""
if status is None:
return _DEFAULT_STATUS
value = status.strip().lower()
return value if value in _ALLOWED_STATUS else _DEFAULT_STATUS
def _jsonb_param(value: Any) -> str:
"""Сериализовать произвольный (возможно Pydantic-содержащий) объект в JSON-строку.
jsonable_encoder разворачивает Pydantic-модели / даты / Enum в JSON-native типы;
json.dumps(..., ensure_ascii=False) кириллица как есть (зеркало pzz_loader).
"""
return json.dumps(jsonable_encoder(value), ensure_ascii=False)
def persist_analysis_run(
db: Session,
*,
cad_num: str,
result: dict[str, Any],
params: dict[str, Any],
district: str | None,
confidence: str | None,
status: str,
schema_version: str,
created_by: str | None,
segment: dict[str, Any] | None = None,
) -> int | None:
"""Сохранить ОДИН завершённый ран анализа в analysis_runs (#994).
Best-effort: обёрнут в SAVEPOINT (db.begin_nested, backend.md) + try/except
провал write НЕ ломает ответ analyze и НЕ отравляет outer-транзакцию (откатывает
только свой savepoint, прошлые reads сессии остаются валидны). На успехе
возвращает id новой строки, на провале None (+ logger.warning/exception).
confidence/status нормализуются под CHECK'и миграции 127 ДО write — IntegrityError
на enum'ах структурно невозможен. advisory всегда TRUE (форсайт-стек советующий).
"""
norm_confidence = _normalize_confidence(confidence)
norm_status = _normalize_status(status)
try:
with db.begin_nested():
row = (
db.execute(
text("""
INSERT INTO analysis_runs (
cad_num, district, segment, params, result,
schema_version, advisory, confidence, status, created_by
)
VALUES (
CAST(:cad_num AS text),
CAST(:district AS text),
CAST(:segment AS jsonb),
CAST(:params AS jsonb),
CAST(:result AS jsonb),
CAST(:schema_version AS text),
TRUE,
CAST(:confidence AS text),
CAST(:status AS text),
CAST(:created_by AS text)
)
RETURNING id
"""),
{
"cad_num": cad_num,
"district": district,
"segment": _jsonb_param(segment) if segment is not None else None,
"params": _jsonb_param(params),
"result": _jsonb_param(result),
"schema_version": schema_version,
"confidence": norm_confidence,
"status": norm_status,
"created_by": created_by,
},
)
.mappings()
.first()
)
new_id = int(row["id"]) if row else None
# SAVEPOINT RELEASE (выход из begin_nested) НЕ персистит — лишь сливает вложенную
# tx в outer. get_db() (core/db.py) коммита-на-успехе НЕ делает (yield→finally:close
# = ROLLBACK), а analyze — единственный write на success-path. Без явного commit
# строка откатится на teardown (silent no-op). Коммитим ВНУТРИ try → провал commit
# тоже best-effort. Паттерн как в trade_in.py / photos.py (explicit commit на write).
db.commit()
logger.info(
"analysis_run persisted: cad=%s id=%s status=%s confidence=%s",
cad_num,
new_id,
norm_status,
norm_confidence,
)
return new_id
except Exception:
# Persist — best-effort: ран уже посчитан, ответ отдаём в любом случае.
# exception (не warning) — нужен traceback, write-провалы должны быть видны.
logger.exception("analysis_run persist failed for cad=%s (response unaffected)", cad_num)
return None
def latest_run_dates(db: Session, cad_nums: list[str]) -> dict[str, datetime]:
"""Дата последнего анализа на участок для набора cad_num (batch, НЕ N+1).
Один запрос к v_analysis_runs_latest (canonical latest-per-parcel из 127):
`WHERE cad_num = ANY(CAST(:cads AS text[]))`. Powers last_analysis_date в by-bbox.
Участки без ранов в результат не попадают (caller трактует отсутствие как None).
Пустой вход пустой dict (без обращения к БД).
"""
if not cad_nums:
return {}
rows = (
db.execute(
text("""
SELECT cad_num, created_at
FROM v_analysis_runs_latest
WHERE cad_num = ANY(CAST(:cads AS text[]))
"""),
{"cads": cad_nums},
)
.mappings()
.all()
)
return {row["cad_num"]: row["created_at"] for row in rows}
def latest_run_for(db: Session, cad_num: str) -> Row[Any] | None:
"""Последний ран на участок целиком (для re-open / «текущий анализ участка»).
Читает из v_analysis_runs_latest (DISTINCT ON cad_num, max created_at). None если
участок ещё не анализировался. Возвращает Row (id/result/params/.../created_at).
"""
return db.execute(
text("""
SELECT id, cad_num, district, segment, params, result,
schema_version, advisory, confidence, status, created_by, created_at
FROM v_analysis_runs_latest
WHERE cad_num = CAST(:cad_num AS text)
"""),
{"cad_num": cad_num},
).first()

View file

@ -8,6 +8,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from typing import Any from typing import Any
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@ -58,7 +59,13 @@ def test_by_bbox_valid_returns_structure(client: TestClient) -> None:
rows = [_make_db_row()] rows = [_make_db_row()]
mock_db = _build_mock_db(rows) mock_db = _build_mock_db(rows)
with patch("app.api.v1.parcels.get_db", return_value=iter([mock_db])): # latest_run_dates патчим явно (#994): mock_db.execute() возвращает те же rows на
# ЛЮБОЙ запрос, поэтому без патча latest_run_dates получил бы parcel-rows (без
# created_at) → KeyError. Пустой dict = «участок не анализировался» → date None.
with (
patch("app.api.v1.parcels.get_db", return_value=iter([mock_db])),
patch("app.api.v1.parcels.latest_run_dates", return_value={}),
):
resp = client.get( resp = client.get(
"/api/v1/parcels/by-bbox", "/api/v1/parcels/by-bbox",
params={ params={
@ -113,7 +120,10 @@ def test_by_bbox_status_overlay_with_user_id(client: TestClient) -> None:
rows = [_make_db_row(user_status="in_work")] rows = [_make_db_row(user_status="in_work")]
mock_db = _build_mock_db(rows) mock_db = _build_mock_db(rows)
with patch("app.api.v1.parcels.get_db", return_value=iter([mock_db])): with (
patch("app.api.v1.parcels.get_db", return_value=iter([mock_db])),
patch("app.api.v1.parcels.latest_run_dates", return_value={}),
):
resp = client.get( resp = client.get(
"/api/v1/parcels/by-bbox", "/api/v1/parcels/by-bbox",
params={ params={
@ -134,7 +144,10 @@ def test_by_bbox_no_user_id_status_is_null(client: TestClient) -> None:
rows = [_make_db_row(user_status="favorite")] # DB вернёт статус rows = [_make_db_row(user_status="favorite")] # DB вернёт статус
mock_db = _build_mock_db(rows) mock_db = _build_mock_db(rows)
with patch("app.api.v1.parcels.get_db", return_value=iter([mock_db])): with (
patch("app.api.v1.parcels.get_db", return_value=iter([mock_db])),
patch("app.api.v1.parcels.latest_run_dates", return_value={}),
):
resp = client.get( resp = client.get(
"/api/v1/parcels/by-bbox", "/api/v1/parcels/by-bbox",
params={ params={
@ -150,3 +163,33 @@ def test_by_bbox_no_user_id_status_is_null(client: TestClient) -> None:
parcel = resp.json()["parcels"][0] parcel = resp.json()["parcels"][0]
# Без user_id статус принудительно null (endpoint не раскрывает чужие статусы) # Без user_id статус принудительно null (endpoint не раскрывает чужие статусы)
assert parcel["status"] is None assert parcel["status"] is None
# ── Test 5: last_analysis_date заполняется из v_analysis_runs_latest (#994) ──
def test_by_bbox_last_analysis_date_from_latest_run() -> None:
"""#994: участок с раном в v_analysis_runs_latest → last_analysis_date = ISO-дата."""
client = TestClient(app)
rows = [_make_db_row()]
mock_db = _build_mock_db(rows)
latest = {"66:41:0204016:10": datetime(2026, 6, 1, 14, 30, 0)}
with (
patch("app.api.v1.parcels.get_db", return_value=iter([mock_db])),
patch("app.api.v1.parcels.latest_run_dates", return_value=latest),
):
resp = client.get(
"/api/v1/parcels/by-bbox",
params={
"min_lat": 56.80,
"min_lon": 60.60,
"max_lat": 56.90,
"max_lon": 60.70,
},
)
assert resp.status_code == 200
parcel = resp.json()["parcels"][0]
# created_at → .date().isoformat() (только дата, без времени), str|None per parcel.py:387
assert parcel["last_analysis_date"] == "2026-06-01"