"use client"; import { useMutation } from "@tanstack/react-query"; import { useCallback, useRef, useState } from "react"; import { HTTPError, apiFetch, apiFetchWithStatus } from "@/lib/api"; import type { ParcelAnalysis } from "@/types/site-finder"; // #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 export interface AnalyzeOptions { /** If set, backend uses this saved profile for weights. */ profileId?: number; /** If set together with no profileId, backend uses user's default profile. */ profileUserId?: string; /** * Inline POI weights override — sent as request body. * Priority: inline → profileId → profileUserId default → system. */ weights?: Record | null; } /** * 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, options, }: { cad: string; options?: AnalyzeOptions; }): Promise => { cancelledRef.current = false; setFetchingState(null); // Build optional query string for weight profile params. const qs = new URLSearchParams(); if (options?.profileId != null) { qs.set("profile_id", String(options.profileId)); } if (options?.profileUserId) { qs.set("profile_user_id", options.profileUserId); } const qsStr = qs.toString(); const analyzeUrl = (cadNum: string) => { const base = `/api/v1/parcels/${encodeURIComponent(cadNum)}/analyze`; return qsStr ? `${base}?${qsStr}` : base; }; // Build optional JSON body for inline weights (#201). const bodyPayload = options?.weights != null ? JSON.stringify({ weights: options.weights }) : undefined; // First request — POST /analyze const first = await apiFetchWithStatus< ParcelAnalysis | AnalyzeAcceptedResponse >(analyzeUrl(cad), { method: "POST", ...(bodyPayload ? { body: bodyPayload, headers: { "Content-Type": "application/json" }, } : {}), }); 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(analyzeUrl(cad), { method: "POST", ...(bodyPayload ? { body: bodyPayload, headers: { "Content-Type": "application/json" }, } : {}), }); 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, }; }