gendesign/frontend/src/components/site-finder/FetchingState.tsx
lekss361 2252236ce2 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.
2026-05-12 08:47:22 +03:00

154 lines
4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}