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<T> + 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.
This commit is contained in:
lekss361 2026-05-12 08:47:22 +03:00
parent f4a865060f
commit 2252236ce2
6 changed files with 783 additions and 18 deletions

View file

@ -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"]

View file

@ -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,
}

View file

@ -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<FeatureCollection | undefined>(
undefined,
);
@ -220,8 +222,17 @@ function SiteFinderContent() {
</div>
)}
{/* Pending skeleton */}
{isPending && (
{/* #93 — Fetching state: backend на 202 Accepted, идёт on-demand NSPD fetch */}
{fetchingState && (
<FetchingState
cadNum={fetchingState.cadNum}
etaSeconds={fetchingState.etaSeconds}
onCancel={cancel}
/>
)}
{/* Pending skeleton — первичный analyze в полёте (но fetching state ещё не активирован) */}
{isPending && !fetchingState && (
<div
style={{
padding: 40,

View file

@ -0,0 +1,154 @@
"use client";
import { useEffect, useState } from "react";
interface Props {
cadNum: string;
etaSeconds: number;
onCancel: () => 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 (
<div
style={{
padding: "32px 24px",
background: "#f0f9ff",
border: "1px solid #bae6fd",
borderRadius: 12,
display: "flex",
flexDirection: "column",
gap: 14,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<span
style={{
display: "inline-block",
width: 20,
height: 20,
border: "2.5px solid #0ea5e9",
borderTopColor: "transparent",
borderRadius: "50%",
animation: "fetch-spin 1s linear infinite",
}}
aria-hidden="true"
/>
<h3
style={{
fontSize: 15,
fontWeight: 600,
color: "#0c4a6e",
margin: 0,
}}
>
Загружаем участок из НСПД
</h3>
</div>
<div style={{ fontSize: 13, color: "#0369a1" }}>
Кадастровый номер{" "}
<code
style={{
fontFamily: "monospace",
background: "#e0f2fe",
padding: "1px 6px",
borderRadius: 4,
fontSize: 12,
}}
>
{cadNum}
</code>{" "}
не был ранее загружен в нашу базу. Обращаемся к НСПД (Национальной
системе пространственных данных). Обычно занимает 15-30 секунд.
</div>
{/* Progress bar */}
<div>
<div
style={{
height: 8,
borderRadius: 4,
background: "#e0f2fe",
overflow: "hidden",
}}
role="progressbar"
aria-valuenow={Math.round(progressPct)}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Прогресс загрузки геометрии"
>
<div
style={{
height: "100%",
width: `${progressPct}%`,
background: "#0ea5e9",
transition: "width 0.5s ease-out",
borderRadius: 4,
}}
/>
</div>
<div
style={{
marginTop: 6,
fontSize: 11,
color: "#0369a1",
display: "flex",
justifyContent: "space-between",
fontVariantNumeric: "tabular-nums",
}}
>
<span>{elapsed} сек</span>
<span>~{etaSeconds} сек ожидаемо</span>
</div>
</div>
<button
type="button"
onClick={onCancel}
style={{
alignSelf: "flex-start",
marginTop: 4,
padding: "6px 14px",
background: "transparent",
border: "1px solid #7dd3fc",
color: "#0369a1",
borderRadius: 6,
fontSize: 12,
cursor: "pointer",
fontWeight: 500,
}}
>
Отменить ожидание
</button>
<style jsx>{`
@keyframes fetch-spin {
to {
transform: rotate(360deg);
}
}
`}</style>
</div>
);
}

View file

@ -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<ParcelAnalysis>(
`/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<ParcelAnalysis> => {
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<FetchStatusResponse>(
`/api/v1/parcels/${encodeURIComponent(cad)}/fetch-status`,
);
if (status.status === "ready") {
// Re-trigger analyze — should be 200 now
setFetchingState(null);
const second = await apiFetch<ParcelAnalysis>(
`/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,
};
}

View file

@ -18,3 +18,48 @@ export async function apiFetch<T>(
}
return response.json() as Promise<T>;
}
/**
* 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<T>(
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 };
}