"""Admin endpoints для ETL operations (#203, #44). POST /api/v1/admin/etl/objective-backfill Запустить fuzzy-match backfill objective_complex_mapping. Поддерживает два режима: - v1 (default): threshold=0.85, match_method='fuzzy_trgm' - v2: threshold=0.80, match_method='fuzzy_v2' — для coverage 8.5%→17% POST /api/v1/admin/etl/nspd-denorm-backfill Запустить backfill nspd_parcels/nspd_buildings из всех nspd_quarter_dumps. GET /api/v1/admin/etl/mapping-review Список авто-маппингов objective_complex_mapping + обогащение для ревью. POST /api/v1/admin/etl/mapping-review/{id}/approve Подтвердить маппинг (is_reviewed=true + note append). POST /api/v1/admin/etl/mapping-review/{id}/reject Отклонить маппинг — DELETE строки (иначе травит mv_layout_velocity §4.2). Auth: gendsgn.ru-wide Caddy basic_auth gate (PR #426). App-level X-Admin-Token header removed 2026-05-23 — двойная auth избыточна для pilot. """ from __future__ import annotations import logging from typing import Annotated, Any from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session from app.core.db import get_db from app.services.etl.mapping_review import ( approve_mapping, list_mapping_review, reject_mapping, ) from app.services.etl.objective_backfill import ( AUTO_ACCEPT_THRESHOLD, AUTO_ACCEPT_THRESHOLD_V2, REVIEW_THRESHOLD, apply_core_matches, auto_apply_matches, find_core_matches, find_match_candidates, trigger_mv_refresh, ) logger = logging.getLogger(__name__) router = APIRouter() @router.post("/objective-backfill") def run_objective_backfill( db: Annotated[Session, Depends(get_db)], dry_run: Annotated[bool, Query(description="Preview без insertions")] = False, refresh_mv: Annotated[ bool, Query(description="REFRESH mv_layout_velocity после backfill") ] = True, v2: Annotated[ bool, Query( description=( "v2 mode: threshold=0.80, match_method='fuzzy_v2'. " "Запускать после v1 run — только для unmapped объектов." ) ), ] = False, ) -> dict[str, object]: """Запустить backfill objective_complex_mapping + опционально REFRESH MV. Ищет DOM.РФ комплексы (is_ekb=true) без mapping и применяет fuzzy match к project_name из objective_corpus_room_month через pg_trgm similarity. Режим v1 (default, ?v2=false): - score >= 0.85 (AUTO_ACCEPT_THRESHOLD): auto-insert, match_method='fuzzy_trgm' - score >= 0.6 (REVIEW_THRESHOLD) и < 0.85: только счётчик review_queue Режим v2 (?v2=true, task #44 coverage expansion): - score >= 0.80 (AUTO_ACCEPT_THRESHOLD_V2): auto-insert, match_method='fuzzy_v2' - is_reviewed=false — требует ручной проверки (false positives вероятны) - Целевой прирост: +~47-80 строк, coverage 8.5% → ~17% Returns dict: auto_accepted: сколько строк вставлено review_queue: сколько кандидатов ниже порога auto-accept skipped: ON CONFLICT + ошибки mv_rows_after_refresh: строк в MV после REFRESH (0 если refresh_mv=False) threshold_used: фактический порог (float) match_method_used: match_method в БД (str) """ threshold = AUTO_ACCEPT_THRESHOLD_V2 if v2 else AUTO_ACCEPT_THRESHOLD method = "fuzzy_v2" if v2 else "fuzzy_trgm" # v2 ищет кандидатов начиная с threshold (не от REVIEW_THRESHOLD) search_min = threshold if v2 else REVIEW_THRESHOLD candidates = find_match_candidates(db, only_unmapped=True, min_threshold=search_min) logger.info( "Backfill candidates found: %d (score >= %.2f, method=%s)", len(candidates), search_min, method, ) result: dict[str, object] = dict( auto_apply_matches( db, candidates, threshold=threshold, match_method=method, dry_run=dry_run ) ) mv_rows = 0 if refresh_mv and not dry_run and result.get("auto_accepted", 0): mv_rows = trigger_mv_refresh(db) logger.info("mv_layout_velocity refreshed after backfill: %d rows", mv_rows) result["mv_rows_after_refresh"] = mv_rows result["threshold_used"] = threshold result["match_method_used"] = method return result @router.post("/objective-core-backfill") def run_objective_core_backfill( db: Annotated[Session, Depends(get_db)], dry_run: Annotated[bool, Query(description="Preview без insertions (default True)")] = True, refresh_mv: Annotated[bool, Query(description="REFRESH mv_layout_velocity после apply")] = True, ) -> dict[str, object]: """Core-name + dev-confirm проход (#2177 шаг 2, match_method='auto_core_dev_v5'). Нормализует ЯДРО имени обеих сторон (normalize_complex_name, #2198) и мапит несопоставленные domrf ЕКБ-объекты на objective-проекты. Пишутся ТОЛЬКО tier_a (ровно 1 кандидат + подтверждение застройщика). tier_b/ambiguous — в counts. Дефолт dry_run=True (безопасно). Реальный insert — ?dry_run=false. Returns dict: inserted / conflict_skipped / error_skipped / tier_a_total — из apply; tier_b / ambiguous / skipped_taken — счётчики отчёта; mv_rows_after_refresh — строк в MV после REFRESH (0 если dry-run / нет вставок). """ report = find_core_matches(db) result: dict[str, object] = dict(apply_core_matches(db, report, dry_run=dry_run)) result.update(report.counts()) mv_rows = 0 if refresh_mv and not dry_run and result.get("inserted", 0): mv_rows = trigger_mv_refresh(db) logger.info("mv_layout_velocity refreshed after core-backfill: %d rows", mv_rows) result["mv_rows_after_refresh"] = mv_rows return result @router.post("/nspd-denorm-backfill") def run_nspd_denorm_backfill( limit: Annotated[ int | None, Query(description="Максимум кварталов для обработки (None = все)"), ] = None, ) -> dict[str, object]: """Запустить Celery backfill: denormalize nspd_quarter_dumps → nspd_parcels/nspd_buildings. Задача идемпотентна (ON CONFLICT DO UPDATE). Безопасно запускать повторно. Возвращает Celery task_id — статус через /flower или celery inspect. Args: limit: если задан — обработать только первые N кварталов ORDER BY quarter_cad. """ from app.workers.tasks.nspd_denorm_backfill import backfill_all_dumps task = backfill_all_dumps.apply_async(kwargs={"limit": limit}) logger.info("nspd-denorm-backfill enqueued: task_id=%s limit=%s", task.id, limit) return {"task_id": task.id, "status": "enqueued", "limit": limit} # ════════════════════════════════════════════════════════════════════════════ # Mapping review — глазами подтвердить/отклонить авто-маппинги (feat/…-review) # ════════════════════════════════════════════════════════════════════════════ @router.get("/mapping-review") def get_mapping_review( db: Annotated[Session, Depends(get_db)], only_unreviewed: Annotated[ bool, Query(description="Только is_reviewed=false (авто-матчи без ревью)") ] = True, limit: Annotated[int, Query(ge=1, le=500, description="Строк на странице")] = 100, offset: Annotated[int, Query(ge=0, description="Смещение страницы")] = 0, ) -> dict[str, Any]: """Список строк objective_complex_mapping + обогащение для ревьюера. Обогащение каждой строки: - domrf_comm_name / domrf_dev_name — latest snapshot по domrf_obj_id; - objective_developers — застройщики objective-проекта (агрегат objective_lots.developer, scoped по project_name). Сорт: is_reviewed asc, match_score asc NULLS FIRST (сомнительные сверху). Returns dict: rows: list обогащённых строк (id, objective_complex_name, objective_project_id, objective_group, domrf_obj_id, match_method, match_score, is_reviewed, note, created_at, domrf_comm_name, domrf_dev_name, objective_developers); total: всего строк (с учётом only_unreviewed, БЕЗ limit/offset); limit / offset / only_unreviewed: эхо параметров. """ return list_mapping_review(db, only_unreviewed=only_unreviewed, limit=limit, offset=offset) @router.post("/mapping-review/{mapping_id}/approve") def approve_mapping_review( mapping_id: int, db: Annotated[Session, Depends(get_db)], ) -> dict[str, Any]: """Подтвердить маппинг: is_reviewed=true + append «approved » в note. 404 если строки с таким id нет. """ result = approve_mapping(db, mapping_id) if result is None: raise HTTPException(status_code=404, detail=f"mapping {mapping_id} не найден") return result @router.post("/mapping-review/{mapping_id}/reject") def reject_mapping_review( mapping_id: int, db: Annotated[Session, Depends(get_db)], ) -> dict[str, Any]: """Отклонить маппинг — DELETE строки (иначе травит mv_layout_velocity §4.2). Удаляемая строка целиком логируется (logger.info) перед commit — в note писать некуда, строки не будет. 404 если id не найден. """ deleted = reject_mapping(db, mapping_id) if deleted is None: raise HTTPException(status_code=404, detail=f"mapping {mapping_id} не найден") return {"status": "deleted", "deleted": deleted}