"use client"; import { useMutation } from "@tanstack/react-query"; import { useCallback, useRef, useState } from "react"; import { HTTPError, apiFetch, apiFetchWithStatus } from "@/lib/api"; import { abortableSleep } from "@/lib/abortableSleep"; 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 * * Отмена (issue #1242): per-call AbortController. cancel() абортит ТОЛЬКО * текущий цикл — новая мутация создаёт свой controller, поэтому * "воскресший" poll-цикл предыдущего вызова не может затронуть состояние * актуального. setFetchingState вызывается только если signal не aborted — * иначе баннер ETA/cancel активного запроса был бы перезаписан зомби-циклом. */ export function useSiteAnalysis() { const [fetchingState, setFetchingState] = useState<{ cadNum: string; jobId: number; etaSeconds: number; } | null>(null); // Per-call AbortController. cancel() абортит только текущий цикл; // конкурентные мутации не делят токен отмены (фикс #1242). const abortRef = useRef(null); const mutation = useMutation({ mutationFn: async ({ cad, options, }: { cad: string; options?: AnalyzeOptions; }): Promise => { // Абортим предыдущий in-flight цикл (если ещё спит / поллит) // и создаём новый controller для текущего вызова. abortRef.current?.abort(); const controller = new AbortController(); abortRef.current = controller; const { signal } = controller; 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", signal, ...(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; // Guard: не перезаписываем баннер если этот вызов уже отменили // (succeeded race: cancel пришёл во время первого POST). if (signal.aborted) { throw new Error("Загрузка отменена пользователем"); } setFetchingState({ cadNum: cad, jobId: accepted.job_id, etaSeconds: accepted.eta_seconds, }); // Poll loop for (let i = 0; i < POLL_MAX_ITERATIONS; i++) { await abortableSleep(POLL_INTERVAL_MS, signal); if (signal.aborted) { throw new Error("Загрузка отменена пользователем"); } const status = await apiFetch( `/api/v1/parcels/${encodeURIComponent(cad)}/fetch-status`, { signal }, ); if (status.status === "ready") { // Re-trigger analyze — should be 200 now. Используем // apiFetchWithStatus, чтобы НЕ принять 202-stub за готовый // ParcelAnalysis (#1339): re-POST может вернуть 202 Accepted // (геометрия ready, но analyze-воркер не закончил — // задокументированная гонка). 202-stub не имеет score/ // score_breakdown → крашит рендер Секций 3/4. На 202 продолжаем // polling (symmetry с первым запросом), как в useParcelAnalyzeQuery. // setFetchingState(null) ПОСЛЕ await чтобы не было flicker: // если очистить до await, mutation isPending=true но // fetchingState=null → пустой экран ~1 RTT. После await // mutation сразу резолвится с data — render skipped. const second = await apiFetchWithStatus< ParcelAnalysis | AnalyzeAcceptedResponse >(analyzeUrl(cad), { method: "POST", signal, ...(bodyPayload ? { body: bodyPayload, headers: { "Content-Type": "application/json" }, } : {}), }); if (second.status === 200) { // Только если этот вызов всё ещё актуален — иначе зомби-цикл // стирает баннер свежей мутации (#1242). if (!signal.aborted) { setFetchingState(null); } return second.body as ParcelAnalysis; } // 202 race → fall through and poll /fetch-status again. continue; } if (status.status === "not_in_nspd") { if (!signal.aborted) setFetchingState(null); throw new HTTPError( 404, status, status.error_msg ?? "Кадастровый номер не найден в НСПД", ); } if (status.status === "failed") { if (!signal.aborted) setFetchingState(null); throw new HTTPError( 503, status, status.error_msg ?? "НСПД временно недоступен", ); } if (status.status === "invalid_format") { if (!signal.aborted) setFetchingState(null); throw new HTTPError( 400, status, status.error_msg ?? "Неверный формат кадастрового номера", ); } if (status.status === "fetching") { // continue polling continue; } // Неизвестный status (#1485): рассинхрон фронт/бэк — опечатка или // новый/переименованный статус вне закрытого union FetchStatus. // Бросаем явную ошибку вместо молчаливого polling до 2-мин таймаута // (что вводило бы в заблуждение «загрузка слишком долгая»). if (!signal.aborted) setFetchingState(null); throw new Error(`Неизвестный статус загрузки: ${status.status}`); } // Polling exhausted (2 min) if (!signal.aborted) setFetchingState(null); throw new Error( "Загрузка длится слишком долго (>2 мин). Попробуйте позже.", ); } throw new Error(`Unexpected response status: ${first.status}`); }, }); const cancel = useCallback(() => { abortRef.current?.abort(); abortRef.current = null; setFetchingState(null); mutation.reset(); }, [mutation]); return { ...mutation, fetchingState, cancel, }; }