From 49fac806fcc9eb7f4b85461ff9a991ffd8b9b512 Mon Sep 17 00:00:00 2001 From: lekss361 <47113017+lekss361@users.noreply.github.com> Date: Tue, 12 May 2026 09:02:17 +0300 Subject: [PATCH] feat(site-finder): auto-fetch cadastre geometry on-demand (#93) (#95) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(site-finder): auto-fetch cadastre geometry on-demand (#93) Когда пользователь вводит 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. * fix(site-finder): address PR #95 auto-review minor feedback Backend (cadastre_fetch.py): 1. (race condition) Advisory lock `pg_try_advisory_xact_lock(hashtext(cad_num))` обёрнут вокруг шагов "check active job → enqueue" в find_or_enqueue_fetch. Lock transaction-scoped, released at COMMIT. Параллельные запросы на тот же cad: первый получает lock и enqueue; второй lock=false → re-check active job (уже виден после первого COMMIT) → возвращает тот же job_id. Docstring обновлён, упоминание SELECT FOR UPDATE удалено. Backend (parcels.py): 3. (threadpool exhaustion) _INLINE_FETCH_WAIT_S снижен 25 → 15s с подробным комментарием: tradeoff про default Starlette anyio threadpool (40 slots) и concurrent burst saturation. 15s баланс: НСПД avg 5-15s для quarter, ~70% fast path; остальные 30% получают 202 + polling без блока. Data (87_on_demand_indexes.sql): 2. (missing index) New migration: - `nspd_geo_targets_cad_num_idx ON nspd_geo_targets(cad_num)` — для find_active/recent_completed_job (existing UNIQUE composite не покрывает WHERE cad_num=:c). - `nspd_geo_jobs_source_status_idx ON nspd_geo_jobs(source_kind, status)` composite для filter auto_on_demand + queued/running. Idempotent (CREATE INDEX IF NOT EXISTS), не блокер при текущем размере, критично при росте on-demand traffic. Frontend (useSiteAnalysis.ts): 4. (UI flicker) setFetchingState(null) перемещён ПОСЛЕ `await second apiFetch`. Иначе между clear и resolve есть момент когда mutation isPending=true + fetchingState=null → пустой экран ~1 RTT. NOT addressed (rebuttal): 5. (Tailwind convention) — проверил: в проекте нет ни globals.css, ни @tailwind directives. ВСЕ существующие site-finder components используют inline styles (ConfidenceBadge, GeotechRiskBlock, ScoreBreakdownPanel etc). Tailwind в deps но не wired up. Keep inline styles для consistency. 6. (animate-spin) — требует Tailwind globals (см. #5). ` + + ); +} diff --git a/frontend/src/hooks/useSiteAnalysis.ts b/frontend/src/hooks/useSiteAnalysis.ts index 29d234b4..7323f45c 100644 --- a/frontend/src/hooks/useSiteAnalysis.ts +++ b/frontend/src/hooks/useSiteAnalysis.ts @@ -1,16 +1,156 @@ "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) ПОСЛЕ await чтобы не было flicker: + // если очистить до await, mutation isPending=true но + // fetchingState=null → пустой экран ~1 RTT. После await + // mutation сразу резолвится с data — render skipped. + const second = await apiFetch( + `/api/v1/parcels/${encodeURIComponent(cad)}/analyze`, + { method: "POST" }, + ); + setFetchingState(null); + 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 }; +}