Когда пользователь вводит 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.
154 lines
4 KiB
TypeScript
154 lines
4 KiB
TypeScript
"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>
|
||
);
|
||
}
|