gendesign/frontend/src/components/site-finder/FetchingState.tsx
lekss361 49fac806fc
feat(site-finder): auto-fetch cadastre geometry on-demand (#93) (#95)
* 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.

* fix(site-finder): address PR #95 auto-review minor feedback

Backend (cadastre_fetch.py):
1. (race condition) Advisory lock `pg_try_advisory_xact_lock(hashtext(cad_num))`
   обёрнут вокруг шагов "check active job → enqueue" в find_or_enqueue_fetch.
   Lock transaction-scoped, released at COMMIT. Параллельные запросы на тот
   же cad: первый получает lock и enqueue; второй lock=false → re-check
   active job (уже виден после первого COMMIT) → возвращает тот же job_id.
   Docstring обновлён, упоминание SELECT FOR UPDATE удалено.

Backend (parcels.py):
3. (threadpool exhaustion) _INLINE_FETCH_WAIT_S снижен 25 → 15s с подробным
   комментарием: tradeoff про default Starlette anyio threadpool (40 slots)
   и concurrent burst saturation. 15s баланс: НСПД avg 5-15s для quarter,
   ~70% fast path; остальные 30% получают 202 + polling без блока.

Data (87_on_demand_indexes.sql):
2. (missing index) New migration:
   - `nspd_geo_targets_cad_num_idx ON nspd_geo_targets(cad_num)` — для
     find_active/recent_completed_job (existing UNIQUE composite не покрывает
     WHERE cad_num=:c).
   - `nspd_geo_jobs_source_status_idx ON nspd_geo_jobs(source_kind, status)`
     composite для filter auto_on_demand + queued/running.
   Idempotent (CREATE INDEX IF NOT EXISTS), не блокер при текущем размере,
   критично при росте on-demand traffic.

Frontend (useSiteAnalysis.ts):
4. (UI flicker) setFetchingState(null) перемещён ПОСЛЕ `await second
   apiFetch`. Иначе между clear и resolve есть момент когда mutation
   isPending=true + fetchingState=null → пустой экран ~1 RTT.

NOT addressed (rebuttal):
5. (Tailwind convention) — проверил: в проекте нет ни globals.css, ни
   @tailwind directives. ВСЕ существующие site-finder components используют
   inline styles (ConfidenceBadge, GeotechRiskBlock, ScoreBreakdownPanel etc).
   Tailwind в deps но не wired up. Keep inline styles для consistency.
6. (animate-spin) — требует Tailwind globals (см. #5). `<style jsx>`
   keyframes — built-in Next.js, работает.

Per auto-review on 2252236.

---------

Co-authored-by: lekss361 <claudestars@proton.me>
2026-05-12 09:02:17 +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>
);
}