From f7eb98b971bdea13da590c55900a530b9d75e438 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Fri, 5 Jun 2026 16:12:21 +0500 Subject: [PATCH] fix(site-finder): handle 202 on-demand geometry in analyze + error boundaries (#1001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical demo-blocker: POST /analyze returns 202 {status:fetching} for parcels whose geometry isn't cached yet (#93 on-demand НСПД fetch). useParcelAnalyzeQuery treated 202 as success → the stub (no score/competitors) reached render → Section 3/4 threw TypeError → white screen (no error boundary). Repro confirmed on prod. - useParcelAnalyzeQuery: 202-aware — poll /fetch-status (2s, 2-min cap), re-POST on ready (status-checked, no stub on 202 race), throw HTTPError on not_in_nspd/failed/invalid_format; horizon preserved in both POSTs; retry: false - error boundaries: app/site-finder/analysis/[cad]/error.tsx + app/error.tsx — no white screen ever; calm RU message in prod, error detail in dev - Section3/Section4: guard competitors/score against partial payloads - CadInput: drop hardcoded default cad (empty + placeholder), raw hex → tokens Map parcel-select already fixed by 3cea915. Part of EPIC #958. --- frontend/src/app/error.tsx | 85 ++++++++++++++++++ .../app/site-finder/analysis/[cad]/error.tsx | 87 +++++++++++++++++++ .../src/components/site-finder/CadInput.tsx | 30 +++++-- .../Section3SettingsAndCompetitors.tsx | 16 ++-- .../site-finder/analysis/Section4Estimate.tsx | 7 +- frontend/src/lib/site-finder-api.ts | 86 ++++++++++++++++-- 6 files changed, 292 insertions(+), 19 deletions(-) create mode 100644 frontend/src/app/error.tsx create mode 100644 frontend/src/app/site-finder/analysis/[cad]/error.tsx diff --git a/frontend/src/app/error.tsx b/frontend/src/app/error.tsx new file mode 100644 index 00000000..da113c8c --- /dev/null +++ b/frontend/src/app/error.tsx @@ -0,0 +1,85 @@ +"use client"; + +import Link from "next/link"; + +/** + * App-level error boundary — global net of last resort (#1001, 958-B5). + * + * Catches render errors not handled by a nested route `error.tsx`, so no route + * in the app can fall through to a blank white screen. + */ +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( +
+
+

+ Что-то пошло не так +

+

+ {process.env.NODE_ENV === "production" + ? "Попробуйте обновить страницу или вернуться на главную." + : error.message || "Произошла непредвиденная ошибка."} +

+
+ + + ← На главную + +
+
+
+ ); +} diff --git a/frontend/src/app/site-finder/analysis/[cad]/error.tsx b/frontend/src/app/site-finder/analysis/[cad]/error.tsx new file mode 100644 index 00000000..e158e656 --- /dev/null +++ b/frontend/src/app/site-finder/analysis/[cad]/error.tsx @@ -0,0 +1,87 @@ +"use client"; + +import Link from "next/link"; + +/** + * Route-level error boundary for the Site Finder analysis page (#1001, 958-B5). + * + * Net of last resort: even if an unforeseen payload (e.g. a 202 stub missing + * `score`/`competitors`) slips past the query/section guards and throws during + * render, the App Router catches it here and shows a graceful card instead of + * a blank white screen. + */ +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( +
+
+

+ Не удалось отобразить анализ участка +

+

+ {process.env.NODE_ENV === "production" + ? "Попробуйте обновить страницу или вернуться к выбору участка." + : error.message || "Произошла непредвиденная ошибка при отрисовке."} +

+
+ + + ← Site Finder + +
+
+
+ ); +} diff --git a/frontend/src/components/site-finder/CadInput.tsx b/frontend/src/components/site-finder/CadInput.tsx index ab1dae4a..414b517e 100644 --- a/frontend/src/components/site-finder/CadInput.tsx +++ b/frontend/src/components/site-finder/CadInput.tsx @@ -13,7 +13,7 @@ interface Props { } export function CadInput({ onSubmit, loading }: Props) { - const [value, setValue] = useState("66:41:0204016:10"); + const [value, setValue] = useState(""); const [error, setError] = useState(null); function handleSubmit(e: React.FormEvent) { @@ -56,7 +56,9 @@ export function CadInput({ onSubmit, loading }: Props) { flex: 1, padding: "8px 12px", fontSize: 14, - border: error ? "1px solid #ef4444" : "1px solid #d1d5db", + border: error + ? "1px solid var(--danger, #B3261E)" + : "1px solid var(--border-strong, #D1D5DB)", borderRadius: 8, outline: "none", fontFamily: "monospace", @@ -70,8 +72,10 @@ export function CadInput({ onSubmit, loading }: Props) { padding: "8px 18px", fontSize: 14, fontWeight: 600, - background: loading ? "#9ca3af" : "#1d4ed8", - color: "#fff", + background: loading + ? "var(--fg-tertiary, #73767E)" + : "var(--accent, #1D4ED8)", + color: "var(--fg-on-dark, #E2E8F0)", border: "none", borderRadius: 8, cursor: loading ? "default" : "pointer", @@ -82,9 +86,23 @@ export function CadInput({ onSubmit, loading }: Props) { {error && ( -

{error}

+

+ {error} +

)} -

+

Примеры: 66:41:0204016:10 (участок) ·{" "} 66:41:0204016 (квартал)

diff --git a/frontend/src/components/site-finder/analysis/Section3SettingsAndCompetitors.tsx b/frontend/src/components/site-finder/analysis/Section3SettingsAndCompetitors.tsx index 8aa08650..c774b3e2 100644 --- a/frontend/src/components/site-finder/analysis/Section3SettingsAndCompetitors.tsx +++ b/frontend/src/components/site-finder/analysis/Section3SettingsAndCompetitors.tsx @@ -605,7 +605,9 @@ function applyFilters( data: ParcelAnalysis, filters: FilterState, ): ParcelAnalysis["competitors"] { - let result = [...data.competitors]; + // Guard: a 202 on-demand-fetch stub (#1001) or older backend may omit the + // array entirely — fall back to empty so we render an empty state, not throw. + let result = [...(data.competitors ?? [])]; // Radius filter — distance_m is in metres from the parcel centroid result = result.filter((c) => c.distance_m <= filters.radiusKm * 1000); @@ -631,6 +633,8 @@ export function Section3SettingsAndCompetitors({ cad, data }: Props) { useState(null); const filteredCompetitors = applyFilters(data, filters); + // Normalise once — a 202 stub / older backend may omit `competitors` (#1001). + const competitorCount = (data.competitors ?? []).length; const districtName = data.district?.district_name ?? null; return ( @@ -660,8 +664,8 @@ export function Section3SettingsAndCompetitors({ cad, data }: Props) { color: "var(--fg-on-dark-muted, #94A3B8)", }} > - {data.competitors.length > 0 - ? `${filteredCompetitors.length} из ${data.competitors.length} объектов в радиусе ${filters.radiusKm} км` + {competitorCount > 0 + ? `${filteredCompetitors.length} из ${competitorCount} объектов в радиусе ${filters.radiusKm} км` : "Конкуренты не найдены в радиусе анализа"} @@ -684,8 +688,8 @@ export function Section3SettingsAndCompetitors({ cad, data }: Props) { {/* Filtered competitors count hint */} - {data.competitors.length > 0 && - filteredCompetitors.length !== data.competitors.length && ( + {competitorCount > 0 && + filteredCompetitors.length !== competitorCount && (
Фильтр по радиусу: показано {filteredCompetitors.length} из{" "} - {data.competitors.length} конкурентов + {competitorCount} конкурентов
)} diff --git a/frontend/src/components/site-finder/analysis/Section4Estimate.tsx b/frontend/src/components/site-finder/analysis/Section4Estimate.tsx index e4fab1f4..9ce12237 100644 --- a/frontend/src/components/site-finder/analysis/Section4Estimate.tsx +++ b/frontend/src/components/site-finder/analysis/Section4Estimate.tsx @@ -39,7 +39,12 @@ interface Props { // ── Helpers ─────────────────────────────────────────────────────────────────── function buildHeadlineTitle(analysis: ParcelAnalysis): string { - const score = analysis.score.toFixed(1); + // Guard: a 202 on-demand-fetch stub (#1001) has no `score` — render "—" + // instead of throwing on `.toFixed` against undefined. + const score = + typeof analysis.score === "number" && Number.isFinite(analysis.score) + ? analysis.score.toFixed(1) + : "—"; const label = analysis.score_label ?? ""; if (label) { return `Оценка: ${score} · ${label.toUpperCase()}`; diff --git a/frontend/src/lib/site-finder-api.ts b/frontend/src/lib/site-finder-api.ts index cb44db49..495b564d 100644 --- a/frontend/src/lib/site-finder-api.ts +++ b/frontend/src/lib/site-finder-api.ts @@ -9,7 +9,11 @@ */ import { keepPreviousData, useQuery } from "@tanstack/react-query"; -import { apiFetch, apiFetchWithStatus } from "@/lib/api"; +import { HTTPError, apiFetch, apiFetchWithStatus } from "@/lib/api"; +import type { + AnalyzeAcceptedResponse, + FetchStatusResponse, +} from "@/hooks/useSiteAnalysis"; import { MOCK_PARCELS_BBOX, MOCK_RECENT_PARCELS, @@ -326,6 +330,13 @@ export interface PoiScoreResponse { // ── Hook: useParcelAnalyzeQuery (B5) ───────────────────────────────────────── +// #93 on-demand NSPD fetch: POST /analyze returns 202 for any parcel whose +// geometry isn't cached yet. We must poll /fetch-status and re-POST once ready, +// mirroring useSiteAnalysis — otherwise the 202 stub (no score/competitors) +// reaches the render tree and crashes Section 3/4 (#1001, 958-B5). +const ANALYZE_POLL_INTERVAL_MS = 2000; +const ANALYZE_POLL_MAX_ITERATIONS = 60; // 60 × 2s = 2 min hard cap + export function useParcelAnalyzeQuery(cad: string, horizon: number = 12) { return useQuery({ queryKey: ["parcel-analyze", cad, horizon], @@ -334,14 +345,77 @@ export function useParcelAnalyzeQuery(cad: string, horizon: number = 12) { // Return fixture data regardless of cad/horizon — for dev only return fixtureAnalyze as ParcelAnalyzeResponse; } + // Backend accepts ?horizon= ∈ {6,12,18} (default 12; 422 otherwise, #995). - return apiFetch( - `/api/v1/parcels/${encodeURIComponent(cad)}/analyze?horizon=${horizon}`, - { method: "POST" }, - ); + const analyzeUrl = `/api/v1/parcels/${encodeURIComponent( + cad, + )}/analyze?horizon=${horizon}`; + + // First request — POST /analyze. apiFetchWithStatus surfaces the 202 + // Accepted code instead of treating it as a successful payload. + const first = await apiFetchWithStatus< + ParcelAnalyzeResponse | AnalyzeAcceptedResponse + >(analyzeUrl, { method: "POST" }); + + // 200 → geometry was cached, full analysis is ready. + if (first.status === 200) { + return first.body as ParcelAnalyzeResponse; + } + + // 202 Accepted → geometry is being fetched from НСПД. Poll /fetch-status + // every 2s; once ready re-POST /analyze (now 200). The query stays + // pending throughout, so the page's "Анализируем участок…" screen shows. + if (first.status === 202) { + for (let i = 0; i < ANALYZE_POLL_MAX_ITERATIONS; i++) { + await new Promise((r) => setTimeout(r, ANALYZE_POLL_INTERVAL_MS)); + const status = await apiFetch( + `/api/v1/parcels/${encodeURIComponent(cad)}/fetch-status`, + ); + if (status.status === "ready") { + // Re-POST should now be 200. If it races back to 202, keep polling + // rather than returning the stub (symmetry with the first request). + const second = await apiFetchWithStatus< + ParcelAnalyzeResponse | AnalyzeAcceptedResponse + >(analyzeUrl, { method: "POST" }); + if (second.status === 200) { + return second.body as ParcelAnalyzeResponse; + } + // 202 race → fall through and poll /fetch-status again. + } + if (status.status === "not_in_nspd") { + throw new HTTPError( + 404, + status, + status.error_msg ?? "Кадастровый номер не найден в НСПД", + ); + } + if (status.status === "failed") { + throw new HTTPError( + 503, + status, + status.error_msg ?? "НСПД временно недоступен", + ); + } + if (status.status === "invalid_format") { + throw new HTTPError( + 400, + status, + status.error_msg ?? "Неверный формат кадастрового номера", + ); + } + // status === "fetching" → continue polling + } + throw new Error( + "Загрузка длится слишком долго (>2 мин). Попробуйте позже.", + ); + } + + throw new Error(`Неожиданный статус ответа: ${first.status}`); }, staleTime: 5 * 60_000, // 5 min — analyze is expensive - retry: 1, + // The queryFn polls internally; a thrown terminal error (404/503/400/ + // timeout) is final and must NOT restart the whole poll loop via retry. + retry: false, // Horizon is in the queryKey, so switching it is a fresh cache entry. Keep // the previous analysis on screen while the new horizon re-fetches instead // of blanking the whole page to the loading state on every selector change.