"""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, *, schema_version: str | None = None, ) -> Row[Any] | None: """Последний ран на участок (для re-open / база форсайта по конкретной схеме). Два режима: • schema_version=None (дефолт, backward-compatible): читает из v_analysis_runs_latest (DISTINCT ON cad_num, max created_at) — последний ран ЛЮБОЙ схемы. Это исходное поведение, существующие вызовы не меняются. • schema_version задан: читает из БАЗОВОЙ таблицы analysis_runs с фильтром по schema_version — последний ран ИМЕННО этой схемы. Нужно форсайт-таске (3b-i), чтобы достать последний site-analysis ран ('analyze-1.0') как базу прогноза, даже когда поверх него уже легли §22-раны ('1.0') — view бы вернул §22-ран. None если подходящего рана нет. Возвращает Row (id/result/params/.../created_at). psycopg v3: CAST(:x AS text) — никогда :x::type (backend.md). """ if schema_version is None: 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() return db.execute( text(""" SELECT id, cad_num, district, segment, params, result, schema_version, advisory, confidence, status, created_by, created_at FROM analysis_runs WHERE cad_num = CAST(:cad_num AS text) AND schema_version = CAST(:schema_version AS text) ORDER BY created_at DESC LIMIT 1 """), {"cad_num": cad_num, "schema_version": schema_version}, ).first() def list_runs_for( db: Session, cad_num: str, *, limit: int = 20, ) -> list[Row[Any]]: """Недавние раны на участок, newest-first (history-список #994). LIGHT-проекция: НЕ тянем тяжёлый `result`-блоб (~90 ключей JSONB) — только метаданные для UI-списка «история анализов». Полную строку (с result) отдаёт `get_run`. Читаем из БАЗОВОЙ таблицы analysis_runs (а НЕ v_analysis_runs_latest — тот DISTINCT ON cad_num и вернул бы максимум 1 строку), фильтр по cad_num, ORDER BY created_at DESC LIMIT :limit. Пустой список, если ранов нет (caller отдаёт 200 + {runs: []}, не 404). psycopg v3: CAST(:x AS type) — никогда :x::type (backend.md). """ return list( db.execute( text(""" SELECT id, cad_num, created_at, status, schema_version, district, confidence, created_by FROM analysis_runs WHERE cad_num = CAST(:cad_num AS text) ORDER BY created_at DESC LIMIT CAST(:limit AS integer) """), {"cad_num": cad_num, "limit": limit}, ).all() ) def get_run(db: Session, run_id: int) -> Row[Any] | None: """Полная строка одного рана по id (включая `result`) для re-open / detail-view. Возвращает Row со всеми колонками (id/cad_num/.../result/created_at) или None, если рана с таким id нет (caller → 404 graceful). Источник — БАЗОВАЯ таблица analysis_runs (id — PK). psycopg v3: CAST(:x AS type) — никогда :x::type. """ return db.execute( text(""" SELECT id, cad_num, district, segment, params, result, schema_version, advisory, confidence, status, created_by, created_at FROM analysis_runs WHERE id = CAST(:run_id AS integer) """), {"run_id": run_id}, ).first()