fix(site-finder): handle 202 on-demand geometry in analyze + error boundaries (#1001)
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 2m52s
Deploy / deploy (push) Successful in 1m1s

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:
Light1YT 2026-06-05 16:12:21 +05:00
parent 740210d9ea
commit f7eb98b971
6 changed files with 292 additions and 19 deletions

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

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

View file

@ -13,7 +13,7 @@ interface Props {
} }
export function CadInput({ onSubmit, loading }: 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); const [error, setError] = useState<string | null>(null);
function handleSubmit(e: React.FormEvent) { function handleSubmit(e: React.FormEvent) {
@ -56,7 +56,9 @@ export function CadInput({ onSubmit, loading }: Props) {
flex: 1, flex: 1,
padding: "8px 12px", padding: "8px 12px",
fontSize: 14, 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, borderRadius: 8,
outline: "none", outline: "none",
fontFamily: "monospace", fontFamily: "monospace",
@ -70,8 +72,10 @@ export function CadInput({ onSubmit, loading }: Props) {
padding: "8px 18px", padding: "8px 18px",
fontSize: 14, fontSize: 14,
fontWeight: 600, fontWeight: 600,
background: loading ? "#9ca3af" : "#1d4ed8", background: loading
color: "#fff", ? "var(--fg-tertiary, #73767E)"
: "var(--accent, #1D4ED8)",
color: "var(--fg-on-dark, #E2E8F0)",
border: "none", border: "none",
borderRadius: 8, borderRadius: 8,
cursor: loading ? "default" : "pointer", cursor: loading ? "default" : "pointer",
@ -82,9 +86,23 @@ export function CadInput({ onSubmit, loading }: Props) {
</button> </button>
</div> </div>
{error && ( {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:10</code> (участок) ·{" "}
<code>66:41:0204016</code> (квартал) <code>66:41:0204016</code> (квартал)
</p> </p>

View file

@ -605,7 +605,9 @@ function applyFilters(
data: ParcelAnalysis, data: ParcelAnalysis,
filters: FilterState, filters: FilterState,
): ParcelAnalysis["competitors"] { ): 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 // Radius filter — distance_m is in metres from the parcel centroid
result = result.filter((c) => c.distance_m <= filters.radiusKm * 1000); result = result.filter((c) => c.distance_m <= filters.radiusKm * 1000);
@ -631,6 +633,8 @@ export function Section3SettingsAndCompetitors({ cad, data }: Props) {
useState<ParcelAnalysisCompetitor | null>(null); useState<ParcelAnalysisCompetitor | null>(null);
const filteredCompetitors = applyFilters(data, filters); 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; const districtName = data.district?.district_name ?? null;
return ( return (
@ -660,8 +664,8 @@ export function Section3SettingsAndCompetitors({ cad, data }: Props) {
color: "var(--fg-on-dark-muted, #94A3B8)", color: "var(--fg-on-dark-muted, #94A3B8)",
}} }}
> >
{data.competitors.length > 0 {competitorCount > 0
? `${filteredCompetitors.length} из ${data.competitors.length} объектов в радиусе ${filters.radiusKm} км` ? `${filteredCompetitors.length} из ${competitorCount} объектов в радиусе ${filters.radiusKm} км`
: "Конкуренты не найдены в радиусе анализа"} : "Конкуренты не найдены в радиусе анализа"}
</div> </div>
</div> </div>
@ -684,8 +688,8 @@ export function Section3SettingsAndCompetitors({ cad, data }: Props) {
</div> </div>
{/* Filtered competitors count hint */} {/* Filtered competitors count hint */}
{data.competitors.length > 0 && {competitorCount > 0 &&
filteredCompetitors.length !== data.competitors.length && ( filteredCompetitors.length !== competitorCount && (
<div <div
style={{ style={{
marginTop: 16, marginTop: 16,
@ -697,7 +701,7 @@ export function Section3SettingsAndCompetitors({ cad, data }: Props) {
}} }}
> >
Фильтр по радиусу: показано {filteredCompetitors.length} из{" "} Фильтр по радиусу: показано {filteredCompetitors.length} из{" "}
{data.competitors.length} конкурентов {competitorCount} конкурентов
</div> </div>
)} )}

View file

@ -39,7 +39,12 @@ interface Props {
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
function buildHeadlineTitle(analysis: ParcelAnalysis): string { 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 ?? ""; const label = analysis.score_label ?? "";
if (label) { if (label) {
return `Оценка: ${score} · ${label.toUpperCase()}`; return `Оценка: ${score} · ${label.toUpperCase()}`;

View file

@ -9,7 +9,11 @@
*/ */
import { keepPreviousData, useQuery } from "@tanstack/react-query"; 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 { import {
MOCK_PARCELS_BBOX, MOCK_PARCELS_BBOX,
MOCK_RECENT_PARCELS, MOCK_RECENT_PARCELS,
@ -326,6 +330,13 @@ export interface PoiScoreResponse {
// ── Hook: useParcelAnalyzeQuery (B5) ───────────────────────────────────────── // ── 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) { export function useParcelAnalyzeQuery(cad: string, horizon: number = 12) {
return useQuery({ return useQuery({
queryKey: ["parcel-analyze", cad, horizon], 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 fixture data regardless of cad/horizon — for dev only
return fixtureAnalyze as ParcelAnalyzeResponse; return fixtureAnalyze as ParcelAnalyzeResponse;
} }
// Backend accepts ?horizon= ∈ {6,12,18} (default 12; 422 otherwise, #995). // Backend accepts ?horizon= ∈ {6,12,18} (default 12; 422 otherwise, #995).
return apiFetch<ParcelAnalyzeResponse>( const analyzeUrl = `/api/v1/parcels/${encodeURIComponent(
`/api/v1/parcels/${encodeURIComponent(cad)}/analyze?horizon=${horizon}`, cad,
{ method: "POST" }, )}/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 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 // 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 // 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. // of blanking the whole page to the loading state on every selector change.