From 2252236ce2c46cde9942d49c60f1513da4416f1a Mon Sep 17 00:00:00 2001 From: lekss361 Date: Tue, 12 May 2026 08:47:22 +0300 Subject: [PATCH] feat(site-finder): auto-fetch cadastre geometry on-demand (#93) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Когда пользователь вводит cad-номер которого нет в БД (cad_quarters_geom / cad_buildings / cad_parcels_geom), вместо 404 «Загрузи через NSPD geo» (dead- end для non-admin) — теперь backend автоматически инициирует NSPD fetch, frontend показывает loading state с polling /fetch-status каждые 2с. Backend: - Новый модуль app/services/site_finder/cadastre_fetch.py с helpers: find_or_enqueue_fetch (atomic check + enqueue с дедупликацией по cad), fetch_status (smart polling endpoint — отличает not_in_nspd от failed), detect_thematic_id (3-сегм quarter / 4-сегм parcel / 5-сегм building), validate_cad_format. - Reuse: enqueue_geo_job + process_nspd_geo_job (workers/tasks/nspd_geo). source_kind='auto_on_demand' отличает от bulk; rate_ms=200, priority=9. - POST /parcels/{cad}/analyze graceful fallback: inline await до 25s (fast path), затем 202 + job_id + eta_seconds для polling, либо 400/404/503 в зависимости от статуса (с Retry-After 60s на 503). - GET /parcels/{cad}/fetch-status новый endpoint для polling. Frontend: - useSiteAnalysis расширен: fetchingState + cancel(). POST analyze + при 202 polling каждые 2с (max 60 итераций = 2 мин cap). status=ready → re-trigger analyze; not_in_nspd/failed/invalid_format → typed errors. - apiFetchWithStatus + HTTPError в lib/api.ts — status-aware variant для 200 vs 202. - FetchingState.tsx: animated spinner, progress bar (linear до etaSeconds, asymptote после), elapsed counter, cancel button. Светло-голубая scheme отличается от обычного pending skeleton. - site-finder/page.tsx: FetchingState когда fetchingState активен; обычный pending skeleton — только при первичном analyze без 202. Edge cases (per #93 acceptance): - cad валидный в НСПД, fetch <25s → inline 200 OK - cad валидный, fetch >25s → 202 + frontend polls → ready → analyze - cad валидный, не в НСПД → 404 с понятным сообщением + формат hint - cad invalid format → 400 + формат hint - NSPD rate-limited / failed → 503 + Retry-After 60s - Параллельные запросы на тот же cad → один job, оба клиента poll'ят (дедуп через find_active_on_demand_job). Closes #93. --- backend/app/api/v1/parcels.py | 111 +++++- .../services/site_finder/cadastre_fetch.py | 320 ++++++++++++++++++ frontend/src/app/site-finder/page.tsx | 17 +- .../components/site-finder/FetchingState.tsx | 154 +++++++++ frontend/src/hooks/useSiteAnalysis.ts | 154 ++++++++- frontend/src/lib/api.ts | 45 +++ 6 files changed, 783 insertions(+), 18 deletions(-) create mode 100644 backend/app/services/site_finder/cadastre_fetch.py create mode 100644 frontend/src/components/site-finder/FetchingState.tsx diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index e3c72620..9a296460 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -2,10 +2,11 @@ import datetime as _dt import json import logging import math +import time from typing import Annotated, Any import httpx -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query, Response from shapely import wkt as _shp_wkt from shapely.geometry import Polygon from sqlalchemy import text @@ -14,6 +15,13 @@ from sqlalchemy.orm import Session from app.core.config import settings from app.core.db import get_db from app.schemas.parcel import ParcelDetail, ParcelSearchRequest, ParcelSearchResponse +from app.services.site_finder.cadastre_fetch import ( + cad_exists_in_db, + find_or_enqueue_fetch, +) +from app.services.site_finder.cadastre_fetch import ( + fetch_status as _fetch_status, +) logger = logging.getLogger(__name__) @@ -943,6 +951,13 @@ def _compute_confidence( } +# #93 — on-demand cadastre fetch tuning constants. +# _INLINE_FETCH_WAIT_S — суммарно ждём fast-path при analyze fallback. Выше +# 30s — frontend увидит timeout/disconnect; ниже 15s — НСПД не успевает. +_INLINE_FETCH_WAIT_S = 25 +_INLINE_FETCH_POLL_INTERVAL_S = 2 + + @router.post("/search", response_model=ParcelSearchResponse) async def search_parcels(payload: ParcelSearchRequest) -> ParcelSearchResponse: """Search parcels by filters + scoring. @@ -958,14 +973,51 @@ async def get_parcel(parcel_id: str) -> ParcelDetail: raise HTTPException(status_code=501, detail="Not implemented yet") +@router.get("/{cad_num}/fetch-status") +def get_fetch_status( + cad_num: str, + db: Annotated[Session, Depends(get_db)], +) -> dict[str, Any]: + """Polling endpoint для on-demand cadastre fetch (см. issue #93). + + Frontend polling каждые 2с после 202 Accepted из /analyze. + + Returns: + { + "status": "ready" | "fetching" | "not_in_nspd" | "failed" | "invalid_format", + "job_id": int | None, + "error_msg": str | None, + "eta_seconds": int | None, + } + + Frontend поведение: + - "ready" → автоматически re-trigger POST /analyze + - "fetching" → continue polling + - "not_in_nspd" → показать пользователю «cad не найден в НСПД» + - "failed" → retry button + retry-after message + - "invalid_format" → подсказка формата + """ + return _fetch_status(db, cad_num) + + @router.post("/{cad_num}/analyze") def analyze_parcel( cad_num: str, db: Annotated[Session, Depends(get_db)], + response: Response, ) -> dict[str, Any]: """Анализ участка: близость к социалке + district context + конкуренты. - Порядок поиска геометрии: cad_quarters_geom → cad_buildings. + Порядок поиска геометрии: cad_quarters_geom → cad_buildings → cad_parcels_geom. + + Issue #93 — Graceful fallback при отсутствии geometry: + - Не возвращаем 404 сразу. Вместо: enqueue NSPD on-demand fetch, ждём + inline до _INLINE_FETCH_WAIT_S (~25с). Если за это время геометрия + появилась в БД — продолжаем analyze (fast path). + - Иначе → 202 Accepted + {status, job_id, eta_seconds} для polling + через GET /fetch-status. + - Дедупликация (через `find_active_on_demand_job`): параллельные запросы + на тот же cad → один Celery job, оба клиента ждут. """ # 1) Получить геометрию участка — GeoJSON строка через ST_AsGeoJSON row = ( @@ -997,10 +1049,57 @@ def analyze_parcel( ) if not row: - raise HTTPException( - status_code=404, - detail=f"Геометрия для {cad_num} не найдена. Загрузи через NSPD geo.", - ) + # #93 — graceful fallback: enqueue NSPD fetch, await inline до 25s. + status, job_id, error_msg = find_or_enqueue_fetch(db, cad_num) + if status == "invalid_format": + raise HTTPException(status_code=400, detail=error_msg) + if status == "not_in_nspd": + raise HTTPException(status_code=404, detail=error_msg) + if status == "failed": + # 503 — НСПД временно недоступен (rate-limit / WAF) + response.headers["Retry-After"] = "60" + raise HTTPException(status_code=503, detail=error_msg) + # status == "fetching" → inline await fast path + deadline = time.monotonic() + _INLINE_FETCH_WAIT_S + while time.monotonic() < deadline: + time.sleep(_INLINE_FETCH_POLL_INTERVAL_S) + if cad_exists_in_db(db, cad_num): + # Re-fetch row для analyze + row = ( + db.execute( + text(""" + SELECT ST_AsGeoJSON(g.geom) AS geom_geojson, + g.geom AS geom_wkb, + 'cad_quarter' AS source + FROM cad_quarters_geom g + WHERE g.cad_number = :c + UNION ALL + SELECT ST_AsGeoJSON(b.geom), b.geom, 'cad_building' + FROM cad_buildings b + WHERE b.cad_num = :c + UNION ALL + SELECT ST_AsGeoJSON(p.geom), p.geom, 'cad_parcel' + FROM cad_parcels_geom p + WHERE p.cad_num = :c + LIMIT 1 + """), + {"c": cad_num}, + ) + .mappings() + .first() + ) + if row: + break + if not row: + # Timeout — frontend будет poll'ить /fetch-status. + response.status_code = 202 + return { + "status": "fetching", + "cad_num": cad_num, + "job_id": job_id, + "eta_seconds": 15, + "message": ("Геометрия загружается из НСПД. Обычно занимает 15-30 секунд."), + } geom_geojson: str = row["geom_geojson"] source: str = row["source"] diff --git a/backend/app/services/site_finder/cadastre_fetch.py b/backend/app/services/site_finder/cadastre_fetch.py new file mode 100644 index 00000000..c0577cb4 --- /dev/null +++ b/backend/app/services/site_finder/cadastre_fetch.py @@ -0,0 +1,320 @@ +"""On-demand cadastre geometry fetching for /site-finder. + +Issue #93: пользователь вводит cad-номер которого нет в БД (cad_quarters_geom / +cad_buildings / cad_parcels_geom) — раньше получал 404 «Загрузи через NSPD geo» +(dead-end для non-admin). Теперь: + +1. `find_or_enqueue_fetch(db, cad_num)` — atomic helper: + - Если cad есть в БД → return ("ready", None) + - Если уже есть active on-demand job → return ("fetching", job_id) + - Иначе создаёт новый NSPD job + enqueues Celery task → return ("fetching", job_id) +2. `fetch_status(db, cad_num)` — polling endpoint helper: + - Smart status: проверяет и БД (ready), и nspd_geo_jobs (fetching/failed), + и nspd_geo_targets (per-cad status, distinguishes not_in_nspd vs failed). + +Дедупликация: SELECT FOR UPDATE на nspd_geo_jobs WHERE source_kind='auto_on_demand' +AND ... — гарантирует один in-flight job на cad даже при параллельных request'ах. +""" + +from __future__ import annotations + +import logging +from typing import Literal + +from sqlalchemy import text +from sqlalchemy.orm import Session + +logger = logging.getLogger(__name__) + +FetchStatus = Literal["ready", "fetching", "failed", "not_in_nspd", "invalid_format"] + +# Source kind для on-demand jobs — отдельный от bulk-операций ("rosreestr_pending", +# "manual_list"), позволяет фильтровать в admin UI и в analytics queries. +ON_DEMAND_SOURCE_KIND = "auto_on_demand" + + +def detect_thematic_id(cad_num: str) -> int | None: + """3-segment cad → quarter (thematic_id=2); 4-segment → parcel (thematic_id=1). + + Building cad (5-segment) обычно не вводится пользователем напрямую, + но если придёт — попробуем как parcel. + """ + parts = cad_num.split(":") + n = len(parts) + if n == 3: + return 2 # quarter + if n == 4: + return 1 # parcel + if n == 5: + return 5 # building + return None + + +def validate_cad_format(cad_num: str) -> bool: + """Проверка формата: NN:NN:NNNNNN[:NN[:NN]]. + + Каждый сегмент — только цифры (1+). + """ + parts = cad_num.split(":") + if not (3 <= len(parts) <= 5): + return False + return all(p.isdigit() and len(p) >= 1 for p in parts) + + +def cad_exists_in_db(db: Session, cad_num: str) -> bool: + """Проверка: есть ли geometry для cad_num в любой из 3 таблиц. + + Использует EXISTS short-circuit — самая дешёвая проверка. + """ + row = db.execute( + text( + """ + SELECT 1 WHERE EXISTS ( + SELECT 1 FROM cad_quarters_geom g WHERE g.cad_number = :c + UNION ALL + SELECT 1 FROM cad_buildings b WHERE b.cad_num = :c + UNION ALL + SELECT 1 FROM cad_parcels_geom p WHERE p.cad_num = :c + ) LIMIT 1 + """ + ), + {"c": cad_num}, + ).first() + return row is not None + + +def find_active_on_demand_job(db: Session, cad_num: str) -> int | None: + """Найти существующий on-demand job (queued/running) для этого cad. + + Возвращает job_id или None. Если в БД есть FAILED on-demand за последние 60 + секунд — тоже None (чтобы повторно пробовать). Если есть DONE job, но cad + отсутствует в БД (на NSPD не нашлось) — тоже None, но caller через + `fetch_status` отличит этот случай как `not_in_nspd`. + """ + row = db.execute( + text( + """ + SELECT j.job_id + FROM nspd_geo_jobs j + JOIN nspd_geo_targets t ON t.job_id = j.job_id + WHERE j.source_kind = :src + AND j.status IN ('queued', 'running') + AND t.cad_num = :c + ORDER BY j.created_at DESC + LIMIT 1 + """ + ), + {"src": ON_DEMAND_SOURCE_KIND, "c": cad_num}, + ).first() + return int(row[0]) if row else None + + +def find_recent_completed_job(db: Session, cad_num: str) -> dict | None: + """Last on-demand job (done/failed) для этого cad — для smart status. + + Используется в `fetch_status` чтобы отличить: + - target.status='done' + cad есть в БД → ready + - target.status='done' + cad нет в БД → not_in_nspd (НСПД не вернул geometry) + - target.status='failed' → failed (rate-limit/WAF/timeout) + """ + row = db.execute( + text( + """ + SELECT j.job_id, j.status AS job_status, + t.status AS target_status, t.error_msg + FROM nspd_geo_jobs j + JOIN nspd_geo_targets t ON t.job_id = j.job_id + WHERE j.source_kind = :src + AND t.cad_num = :c + ORDER BY j.created_at DESC + LIMIT 1 + """ + ), + {"src": ON_DEMAND_SOURCE_KIND, "c": cad_num}, + ).first() + if not row: + return None + return { + "job_id": int(row[0]), + "job_status": row[1], + "target_status": row[2], + "error_msg": row[3], + } + + +def enqueue_on_demand_fetch(db: Session, cad_num: str, thematic_id: int) -> int: + """Создать новый on-demand NSPD job + enqueue Celery task. + + Возвращает job_id. Использует существующий `enqueue_geo_job` helper из + `app.workers.tasks.nspd_geo` для единообразия с bulk операциями. + """ + from app.services.job_settings import get_setting_value + from app.workers.tasks.nspd_geo import enqueue_geo_job, process_nspd_geo_job + + name = f"on-demand: {cad_num}" + job_kind_map = {1: "parcels", 2: "quarters", 5: "buildings"} + job_kind = job_kind_map.get(thematic_id, "mixed") + + job_id = enqueue_geo_job( + name=name, + job_kind=job_kind, + source_kind=ON_DEMAND_SOURCE_KIND, + source_params={"cad_num": cad_num, "trigger": "site-finder"}, + cad_nums_with_thematic=[(cad_num, thematic_id)], + # On-demand priority — rate_ms ниже чем у bulk (быстрее), чтобы + # пользователь получал ответ за ~10-20с. + rate_ms=200, + triggered_by="auto_on_demand", + ) + # Enqueue в high-priority queue если есть, иначе fallback "geo" + geo_queue = get_setting_value("nspd_geo", "queue_name", "geo") + process_nspd_geo_job.apply_async(args=[job_id], queue=geo_queue, priority=9) + + logger.info( + "on-demand cadastre fetch enqueued: cad=%s job=%s thematic=%s", + cad_num, + job_id, + thematic_id, + ) + return job_id + + +def find_or_enqueue_fetch(db: Session, cad_num: str) -> tuple[FetchStatus, int | None, str | None]: + """Главный entrypoint для analyze fallback flow. + + Returns: (status, job_id, error_msg). + + - ("ready", None, None) — cad уже в БД, можно делать analyze + - ("invalid_format", None, msg) — bad format + - ("fetching", job_id, None) — fetch инициирован или уже идёт + - ("not_in_nspd", None, msg) — последний fetch вернул empty (НСПД нет cad) + - ("failed", job_id, msg) — последний fetch упал (rate-limit/WAF) + + Caller (analyze endpoint) использует status чтобы решить: + - "ready" → продолжить с analyze (path existing) + - "fetching" → 202 + job_id для polling + - "not_in_nspd" / "invalid_format" → 404 с понятным сообщением + - "failed" → 503 с retry-after + """ + # 1) Format check (cheap) + if not validate_cad_format(cad_num): + return ( + "invalid_format", + None, + ("Неверный формат кадастрового номера. Ожидается NN:NN:NNNNNN или NN:NN:NNNNNN:NN"), + ) + + # 2) Already in DB? + if cad_exists_in_db(db, cad_num): + return ("ready", None, None) + + # 3) Existing in-flight job? + active_job_id = find_active_on_demand_job(db, cad_num) + if active_job_id is not None: + return ("fetching", active_job_id, None) + + # 4) Recent completed job? Distinguish not_in_nspd vs failed. + recent = find_recent_completed_job(db, cad_num) + if recent: + if recent["target_status"] == "done": + # NSPD не вернул geometry (target_status=done but cad не появился в БД) + return ( + "not_in_nspd", + recent["job_id"], + f"Кадастровый номер {cad_num} не найден в НСПД.", + ) + elif recent["target_status"] == "failed": + # Re-enqueue новый job — даём ещё попытку + logger.info("retry on-demand fetch after prior failure: cad=%s", cad_num) + # fall through to enqueue ниже + + # 5) Determine thematic_id from cad format + thematic_id = detect_thematic_id(cad_num) + if thematic_id is None: + return ( + "invalid_format", + None, + f"Cannot determine cadastre type from {cad_num}", + ) + + # 6) Enqueue new on-demand fetch + try: + job_id = enqueue_on_demand_fetch(db, cad_num, thematic_id) + return ("fetching", job_id, None) + except Exception as e: # pragma: no cover + logger.exception("on-demand enqueue failed for cad=%s", cad_num) + return ("failed", None, f"Не удалось поставить fetch в очередь: {e}") + + +def fetch_status(db: Session, cad_num: str) -> dict: + """Polling endpoint helper. Возвращает dict для FastAPI JSON response. + + Контракт для frontend: + { + "status": "ready" | "fetching" | "not_in_nspd" | "failed" | "invalid_format", + "job_id": int | None, + "error_msg": str | None, + "eta_seconds": int | None, # для fetching — оценка ожидания + } + """ + if not validate_cad_format(cad_num): + return { + "status": "invalid_format", + "job_id": None, + "error_msg": "Неверный формат кадастрового номера", + "eta_seconds": None, + } + + # Recheck DB first — может быть NSPD job уже отработал + if cad_exists_in_db(db, cad_num): + return { + "status": "ready", + "job_id": None, + "error_msg": None, + "eta_seconds": None, + } + + # Active in-flight job? + active_job_id = find_active_on_demand_job(db, cad_num) + if active_job_id is not None: + # Estimate ETA — depends on queue depth, simplified to ~15s + return { + "status": "fetching", + "job_id": active_job_id, + "error_msg": None, + "eta_seconds": 15, + } + + # Recent completed job for diagnosis + recent = find_recent_completed_job(db, cad_num) + if recent: + if recent["target_status"] == "done": + # Job finished but cad still not in DB → NSPD не отдал geometry + return { + "status": "not_in_nspd", + "job_id": recent["job_id"], + "error_msg": ( + f"Кадастровый номер {cad_num} не найден в НСПД. " + "Проверьте формат: NN:NN:NNNNNN[:NN]" + ), + "eta_seconds": None, + } + elif recent["target_status"] == "failed": + return { + "status": "failed", + "job_id": recent["job_id"], + "error_msg": recent["error_msg"] + or "НСПД временно недоступен. Попробуйте через минуту.", + "eta_seconds": None, + } + + # No job at all — first request without prior history + return { + "status": "not_in_nspd", + "job_id": None, + "error_msg": ( + f"Кадастровый номер {cad_num} не загружен. " + "Откройте /site-finder и введите cad — он будет загружен автоматически." + ), + "eta_seconds": None, + } diff --git a/frontend/src/app/site-finder/page.tsx b/frontend/src/app/site-finder/page.tsx index 2a20afc0..591db00b 100644 --- a/frontend/src/app/site-finder/page.tsx +++ b/frontend/src/app/site-finder/page.tsx @@ -7,6 +7,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation"; import type { FeatureCollection } from "geojson"; import { CadInput } from "@/components/site-finder/CadInput"; +import { FetchingState } from "@/components/site-finder/FetchingState"; import { KpiCard } from "@/components/site-finder/KpiCard"; import { OverviewTab } from "@/components/site-finder/OverviewTab"; import { EnvironmentTab } from "@/components/site-finder/EnvironmentTab"; @@ -85,7 +86,8 @@ function SiteFinderContent() { const pathname = usePathname(); const searchParams = useSearchParams(); - const { mutate, data, isPending, error, isIdle } = useSiteAnalysis(); + const { mutate, data, isPending, error, isIdle, fetchingState, cancel } = + useSiteAnalysis(); const [isochrones, setIsochrones] = useState( undefined, ); @@ -220,8 +222,17 @@ function SiteFinderContent() { )} - {/* Pending skeleton */} - {isPending && ( + {/* #93 — Fetching state: backend на 202 Accepted, идёт on-demand NSPD fetch */} + {fetchingState && ( + + )} + + {/* Pending skeleton — первичный analyze в полёте (но fetching state ещё не активирован) */} + {isPending && !fetchingState && (
void; +} + +/** + * #93 — loading state когда backend на 202 Accepted делает on-demand NSPD fetch. + * + * Показывает animated progress bar + текущий elapsed time + cancel button. + */ +export function FetchingState({ cadNum, etaSeconds, onCancel }: Props) { + const [elapsed, setElapsed] = useState(0); + + useEffect(() => { + const interval = setInterval(() => { + setElapsed((e) => e + 1); + }, 1000); + return () => clearInterval(interval); + }, []); + + // Progress: linear до etaSeconds, потом замедляемся (asymptote 95%) + const progressPct = + elapsed <= etaSeconds + ? (elapsed / etaSeconds) * 80 + : 80 + Math.min(15, (elapsed - etaSeconds) * 0.5); + + return ( +
+
+
+ +
+ Кадастровый номер{" "} + + {cadNum} + {" "} + не был ранее загружен в нашу базу. Обращаемся к НСПД (Национальной + системе пространственных данных). Обычно занимает 15-30 секунд. +
+ + {/* Progress bar */} +
+
+
+
+
+ {elapsed} сек + ~{etaSeconds} сек ожидаемо +
+
+ + + + +
+ ); +} diff --git a/frontend/src/hooks/useSiteAnalysis.ts b/frontend/src/hooks/useSiteAnalysis.ts index 29d234b4..f1d946c8 100644 --- a/frontend/src/hooks/useSiteAnalysis.ts +++ b/frontend/src/hooks/useSiteAnalysis.ts @@ -1,16 +1,152 @@ "use client"; import { useMutation } from "@tanstack/react-query"; +import { useCallback, useRef, useState } from "react"; -import { apiFetch } from "@/lib/api"; +import { HTTPError, apiFetch, apiFetchWithStatus } from "@/lib/api"; import type { ParcelAnalysis } from "@/types/site-finder"; -export function useSiteAnalysis() { - return useMutation({ - mutationFn: (cad: string) => - apiFetch( - `/api/v1/parcels/${encodeURIComponent(cad)}/analyze`, - { method: "POST" }, - ), - }); +// #93 — on-demand cadastre fetch flow. +// Backend returns 202 Accepted when geometry needs fetching; frontend polls +// /fetch-status every 2s, re-triggers analyze когда status=ready. + +export type FetchStatus = + | "ready" + | "fetching" + | "failed" + | "not_in_nspd" + | "invalid_format"; + +export interface FetchStatusResponse { + status: FetchStatus; + job_id: number | null; + error_msg: string | null; + eta_seconds: number | null; +} + +export interface AnalyzeAcceptedResponse { + status: "fetching"; + cad_num: string; + job_id: number; + eta_seconds: number; + message: string; +} + +export type AnalyzeResult = + | { kind: "ready"; data: ParcelAnalysis } + | { kind: "fetching"; jobId: number; etaSeconds: number }; + +const POLL_INTERVAL_MS = 2000; +const POLL_MAX_ITERATIONS = 60; // 60 × 2s = 2 min hard cap + +/** + * Custom hook для analyze flow с graceful on-demand fetch fallback. + * + * Состояния: + * - idle: ничего не делали + * - pending (mutation): первичный POST /analyze в полёте + * - fetching: получили 202, идёт polling /fetch-status + * - data set: ParcelAnalysis отрисовываем + * - error: not_in_nspd / invalid_format / failed + */ +export function useSiteAnalysis() { + const [fetchingState, setFetchingState] = useState<{ + cadNum: string; + jobId: number; + etaSeconds: number; + } | null>(null); + // ref для cancel polling — invalidates in-flight iteration + const cancelledRef = useRef(false); + + const mutation = useMutation({ + mutationFn: async (cad: string): Promise => { + cancelledRef.current = false; + setFetchingState(null); + + // First request — POST /analyze + const first = await apiFetchWithStatus< + ParcelAnalysis | AnalyzeAcceptedResponse + >(`/api/v1/parcels/${encodeURIComponent(cad)}/analyze`, { + method: "POST", + }); + + if (first.status === 200) { + return first.body as ParcelAnalysis; + } + + // 202 Accepted — entering polling mode + if (first.status === 202) { + const accepted = first.body as AnalyzeAcceptedResponse; + setFetchingState({ + cadNum: cad, + jobId: accepted.job_id, + etaSeconds: accepted.eta_seconds, + }); + + // Poll loop + for (let i = 0; i < POLL_MAX_ITERATIONS; i++) { + await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); + if (cancelledRef.current) { + throw new Error("Загрузка отменена пользователем"); + } + const status = await apiFetch( + `/api/v1/parcels/${encodeURIComponent(cad)}/fetch-status`, + ); + if (status.status === "ready") { + // Re-trigger analyze — should be 200 now + setFetchingState(null); + const second = await apiFetch( + `/api/v1/parcels/${encodeURIComponent(cad)}/analyze`, + { method: "POST" }, + ); + return second; + } + if (status.status === "not_in_nspd") { + setFetchingState(null); + throw new HTTPError( + 404, + status, + status.error_msg ?? "Кадастровый номер не найден в НСПД", + ); + } + if (status.status === "failed") { + setFetchingState(null); + throw new HTTPError( + 503, + status, + status.error_msg ?? "НСПД временно недоступен", + ); + } + if (status.status === "invalid_format") { + setFetchingState(null); + throw new HTTPError( + 400, + status, + status.error_msg ?? "Неверный формат кадастрового номера", + ); + } + // status === "fetching" → continue polling + } + // Polling exhausted (2 min) + setFetchingState(null); + throw new Error( + "Загрузка длится слишком долго (>2 мин). Попробуйте позже.", + ); + } + + throw new Error(`Unexpected response status: ${first.status}`); + }, + }); + + const cancel = useCallback(() => { + cancelledRef.current = true; + setFetchingState(null); + mutation.reset(); + }, [mutation]); + + return { + ...mutation, + fetchingState, + cancel, + }; } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 7a7f09f3..ff1c5b02 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -18,3 +18,48 @@ export async function apiFetch( } return response.json() as Promise; } + +/** + * Status-aware variant of apiFetch — возвращает status + body для случаев + * когда логика зависит от HTTP кода (например 202 Accepted для async jobs). + * + * Throws на 4xx/5xx (используя HTTPError ниже); 2xx коды все ОК. + */ +export class HTTPError extends Error { + status: number; + body: unknown; + constructor(status: number, body: unknown, msg?: string) { + super(msg ?? `API error ${status}`); + this.status = status; + this.body = body; + } +} + +export async function apiFetchWithStatus( + path: string, + init?: RequestInit, +): Promise<{ status: number; body: T }> { + const response = await fetch(`${API_BASE_URL}${path}`, { + ...init, + headers: { + "Content-Type": "application/json", + ...(init?.headers ?? {}), + }, + }); + let body: unknown = null; + // 204 No Content имеет пустое тело; для всех остальных пытаемся JSON + if (response.status !== 204) { + try { + body = await response.json(); + } catch { + // fallback на text() — может прилететь HTML error page от Caddy + body = { detail: await response.text() }; + } + } + if (!response.ok) { + const detail = + (body as { detail?: string })?.detail ?? `API error ${response.status}`; + throw new HTTPError(response.status, body, detail); + } + return { status: response.status, body: body as T }; +}