fix(site-finder): handle 202 on-demand geometry in analyze + error boundaries (#1001)
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.
This commit is contained in:
parent
740210d9ea
commit
f7eb98b971
6 changed files with 292 additions and 19 deletions
85
frontend/src/app/error.tsx
Normal file
85
frontend/src/app/error.tsx
Normal file
|
|
@ -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 (
|
||||
<main style={{ padding: "24px 20px", maxWidth: 1400, margin: "0 auto" }}>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 24,
|
||||
background: "var(--bg-card, #FFFFFF)",
|
||||
border: "1px solid var(--border-card, #E6E8EC)",
|
||||
borderRadius: 12,
|
||||
padding: "24px",
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 18,
|
||||
fontWeight: 600,
|
||||
color: "var(--fg-primary, #111111)",
|
||||
}}
|
||||
>
|
||||
Что-то пошло не так
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
margin: "12px 0 0",
|
||||
fontSize: 13,
|
||||
color: "var(--fg-secondary, #5B6066)",
|
||||
}}
|
||||
>
|
||||
{process.env.NODE_ENV === "production"
|
||||
? "Попробуйте обновить страницу или вернуться на главную."
|
||||
: error.message || "Произошла непредвиденная ошибка."}
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: 12, marginTop: 24 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
style={{
|
||||
padding: "8px 18px",
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
background: "var(--accent, #1D4ED8)",
|
||||
color: "var(--fg-on-dark, #E2E8F0)",
|
||||
border: "none",
|
||||
borderRadius: 8,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Повторить
|
||||
</button>
|
||||
<Link
|
||||
href="/"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
padding: "8px 18px",
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
color: "var(--accent, #1D4ED8)",
|
||||
textDecoration: "none",
|
||||
}}
|
||||
>
|
||||
← На главную
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
87
frontend/src/app/site-finder/analysis/[cad]/error.tsx
Normal file
87
frontend/src/app/site-finder/analysis/[cad]/error.tsx
Normal file
|
|
@ -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 (
|
||||
<main style={{ padding: "24px 20px", maxWidth: 1400, margin: "0 auto" }}>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 24,
|
||||
background: "var(--bg-card, #FFFFFF)",
|
||||
border: "1px solid var(--border-card, #E6E8EC)",
|
||||
borderRadius: 12,
|
||||
padding: "24px",
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 18,
|
||||
fontWeight: 600,
|
||||
color: "var(--fg-primary, #111111)",
|
||||
}}
|
||||
>
|
||||
Не удалось отобразить анализ участка
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
margin: "12px 0 0",
|
||||
fontSize: 13,
|
||||
color: "var(--fg-secondary, #5B6066)",
|
||||
}}
|
||||
>
|
||||
{process.env.NODE_ENV === "production"
|
||||
? "Попробуйте обновить страницу или вернуться к выбору участка."
|
||||
: error.message || "Произошла непредвиденная ошибка при отрисовке."}
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: 12, marginTop: 24 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
style={{
|
||||
padding: "8px 18px",
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
background: "var(--accent, #1D4ED8)",
|
||||
color: "var(--fg-on-dark, #E2E8F0)",
|
||||
border: "none",
|
||||
borderRadius: 8,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Повторить
|
||||
</button>
|
||||
<Link
|
||||
href="/site-finder"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
padding: "8px 18px",
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
color: "var(--accent, #1D4ED8)",
|
||||
textDecoration: "none",
|
||||
}}
|
||||
>
|
||||
← Site Finder
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<string | null>(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) {
|
|||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<p style={{ marginTop: 6, fontSize: 12, color: "#ef4444" }}>{error}</p>
|
||||
<p
|
||||
style={{
|
||||
marginTop: 8,
|
||||
fontSize: 12,
|
||||
color: "var(--danger, #B3261E)",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<p style={{ marginTop: 4, fontSize: 11, color: "#9ca3af" }}>
|
||||
<p
|
||||
style={{
|
||||
marginTop: 4,
|
||||
fontSize: 11,
|
||||
color: "var(--fg-tertiary, #73767E)",
|
||||
}}
|
||||
>
|
||||
Примеры: <code>66:41:0204016:10</code> (участок) ·{" "}
|
||||
<code>66:41:0204016</code> (квартал)
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -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<ParcelAnalysisCompetitor | null>(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} км`
|
||||
: "Конкуренты не найдены в радиусе анализа"}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -684,8 +688,8 @@ export function Section3SettingsAndCompetitors({ cad, data }: Props) {
|
|||
</div>
|
||||
|
||||
{/* Filtered competitors count hint */}
|
||||
{data.competitors.length > 0 &&
|
||||
filteredCompetitors.length !== data.competitors.length && (
|
||||
{competitorCount > 0 &&
|
||||
filteredCompetitors.length !== competitorCount && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
|
|
@ -697,7 +701,7 @@ export function Section3SettingsAndCompetitors({ cad, data }: Props) {
|
|||
}}
|
||||
>
|
||||
Фильтр по радиусу: показано {filteredCompetitors.length} из{" "}
|
||||
{data.competitors.length} конкурентов
|
||||
{competitorCount} конкурентов
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()}`;
|
||||
|
|
|
|||
|
|
@ -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<ParcelAnalyzeResponse>(
|
||||
`/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<FetchStatusResponse>(
|
||||
`/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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue