gendesign/frontend/src/hooks/useSiteAnalysis.ts
Light1YT 86e9ea2937 fix(week-review): автофиксы код-ревью — 169 issue (label «week ревью 1»)
Многоагентный аудит + имплементация: один воркер на файл, точечные правки.
Верификация: py_compile (47/47 .py) + tsc --noEmit (0 ошибок). Unit-тесты
не прогонялись (окружение не поднято: rollup native dep / нет pytest-venv).

Полностью исправлено (169): #1336, #1337, #1339, #1340, #1341, #1342, #1343, #1345, #1346, #1348, #1349, #1350, #1351, #1354, #1356, #1358, #1359, #1360, #1362, #1364, #1365, #1366, #1367, #1368, #1369, #1370, #1371, #1372, #1373, #1374, #1375, #1376, #1377, #1378, #1379, #1380, #1381, #1382, #1384, #1385, #1386, #1387, #1388, #1389, #1390, #1391, #1392, #1394, #1395, #1396, #1397, #1399, #1400, #1401, #1402, #1403, #1404, #1408, #1409, #1410, #1411, #1412, #1413, #1414, #1415, #1416, #1417, #1418, #1420, #1423, #1425, #1426, #1427, #1428, #1429, #1430, #1431, #1432, #1433, #1434, #1435, #1437, #1438, #1439, #1440, #1441, #1442, #1443, #1444, #1445, #1446, #1447, #1448, #1449, #1450, #1451, #1452, #1453, #1454, #1455, #1456, #1457, #1458, #1459, #1460, #1461, #1462, #1463, #1464, #1465, #1466, #1467, #1468, #1469, #1471, #1472, #1473, #1474, #1476, #1478, #1479, #1481, #1482, #1483, #1484, #1485, #1487, #1488, #1489, #1490, #1491, #1492, #1493, #1494, #1495, #1496, #1497, #1499, #1500, #1501, #1502, #1504, #1505, #1506, #1507, #1510, #1514, #1515, #1516, #1517, #1518, #1519, #1521, #1522, #1523, #1524, #1525, #1526, #1527, #1528, #1529, #1531, #1532, #1533, #1534, #1535, #1536, #1537, #1538

Частично (9, in-file часть, остаток cross-file): #1361, #1419, #1422, #1424, #1470, #1475, #1477, #1480, #1498
Требуют cross-file (3, не тронуты): #1338, #1363, #1421
Пропущено (1): #1539

Не входило в партию: 22 needs-Leha issue (нужны решения владельца).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 20:21:11 +05:00

253 lines
9.7 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 { useMutation } from "@tanstack/react-query";
import { useCallback, useRef, useState } from "react";
import { HTTPError, apiFetch, apiFetchWithStatus } from "@/lib/api";
import { abortableSleep } from "@/lib/abortableSleep";
import type { ParcelAnalysis } from "@/types/site-finder";
// #93 — on-demand cadastre fetch flow.
// Backend returns 202 Accepted when geometry needs fetching; frontend polls
// /fetch-status every 2s, re-triggers analyze когда status=ready.
export type FetchStatus =
| "ready"
| "fetching"
| "failed"
| "not_in_nspd"
| "invalid_format";
export interface FetchStatusResponse {
status: FetchStatus;
job_id: number | null;
error_msg: string | null;
eta_seconds: number | null;
}
export interface AnalyzeAcceptedResponse {
status: "fetching";
cad_num: string;
job_id: number;
eta_seconds: number;
message: string;
}
export type AnalyzeResult =
| { kind: "ready"; data: ParcelAnalysis }
| { kind: "fetching"; jobId: number; etaSeconds: number };
const POLL_INTERVAL_MS = 2000;
const POLL_MAX_ITERATIONS = 60; // 60 × 2s = 2 min hard cap
export interface AnalyzeOptions {
/** If set, backend uses this saved profile for weights. */
profileId?: number;
/** If set together with no profileId, backend uses user's default profile. */
profileUserId?: string;
/**
* Inline POI weights override — sent as request body.
* Priority: inline → profileId → profileUserId default → system.
*/
weights?: Record<string, number> | null;
}
/**
* Custom hook для analyze flow с graceful on-demand fetch fallback.
*
* Состояния:
* - idle: ничего не делали
* - pending (mutation): первичный POST /analyze в полёте
* - fetching: получили 202, идёт polling /fetch-status
* - data set: ParcelAnalysis отрисовываем
* - error: not_in_nspd / invalid_format / failed
*
* Отмена (issue #1242): per-call AbortController. cancel() абортит ТОЛЬКО
* текущий цикл — новая мутация создаёт свой controller, поэтому
* "воскресший" poll-цикл предыдущего вызова не может затронуть состояние
* актуального. setFetchingState вызывается только если signal не aborted —
* иначе баннер ETA/cancel активного запроса был бы перезаписан зомби-циклом.
*/
export function useSiteAnalysis() {
const [fetchingState, setFetchingState] = useState<{
cadNum: string;
jobId: number;
etaSeconds: number;
} | null>(null);
// Per-call AbortController. cancel() абортит только текущий цикл;
// конкурентные мутации не делят токен отмены (фикс #1242).
const abortRef = useRef<AbortController | null>(null);
const mutation = useMutation({
mutationFn: async ({
cad,
options,
}: {
cad: string;
options?: AnalyzeOptions;
}): Promise<ParcelAnalysis> => {
// Абортим предыдущий in-flight цикл (если ещё спит / поллит)
// и создаём новый controller для текущего вызова.
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
const { signal } = controller;
setFetchingState(null);
// Build optional query string for weight profile params.
const qs = new URLSearchParams();
if (options?.profileId != null) {
qs.set("profile_id", String(options.profileId));
}
if (options?.profileUserId) {
qs.set("profile_user_id", options.profileUserId);
}
const qsStr = qs.toString();
const analyzeUrl = (cadNum: string) => {
const base = `/api/v1/parcels/${encodeURIComponent(cadNum)}/analyze`;
return qsStr ? `${base}?${qsStr}` : base;
};
// Build optional JSON body for inline weights (#201).
const bodyPayload =
options?.weights != null
? JSON.stringify({ weights: options.weights })
: undefined;
// First request — POST /analyze
const first = await apiFetchWithStatus<
ParcelAnalysis | AnalyzeAcceptedResponse
>(analyzeUrl(cad), {
method: "POST",
signal,
...(bodyPayload
? {
body: bodyPayload,
headers: { "Content-Type": "application/json" },
}
: {}),
});
if (first.status === 200) {
return first.body as ParcelAnalysis;
}
// 202 Accepted — entering polling mode
if (first.status === 202) {
const accepted = first.body as AnalyzeAcceptedResponse;
// Guard: не перезаписываем баннер если этот вызов уже отменили
// (succeeded race: cancel пришёл во время первого POST).
if (signal.aborted) {
throw new Error("Загрузка отменена пользователем");
}
setFetchingState({
cadNum: cad,
jobId: accepted.job_id,
etaSeconds: accepted.eta_seconds,
});
// Poll loop
for (let i = 0; i < POLL_MAX_ITERATIONS; i++) {
await abortableSleep(POLL_INTERVAL_MS, signal);
if (signal.aborted) {
throw new Error("Загрузка отменена пользователем");
}
const status = await apiFetch<FetchStatusResponse>(
`/api/v1/parcels/${encodeURIComponent(cad)}/fetch-status`,
{ signal },
);
if (status.status === "ready") {
// Re-trigger analyze — should be 200 now. Используем
// apiFetchWithStatus, чтобы НЕ принять 202-stub за готовый
// ParcelAnalysis (#1339): re-POST может вернуть 202 Accepted
// (геометрия ready, но analyze-воркер не закончил —
// задокументированная гонка). 202-stub не имеет score/
// score_breakdown → крашит рендер Секций 3/4. На 202 продолжаем
// polling (symmetry с первым запросом), как в useParcelAnalyzeQuery.
// setFetchingState(null) ПОСЛЕ await чтобы не было flicker:
// если очистить до await, mutation isPending=true но
// fetchingState=null → пустой экран ~1 RTT. После await
// mutation сразу резолвится с data — render skipped.
const second = await apiFetchWithStatus<
ParcelAnalysis | AnalyzeAcceptedResponse
>(analyzeUrl(cad), {
method: "POST",
signal,
...(bodyPayload
? {
body: bodyPayload,
headers: { "Content-Type": "application/json" },
}
: {}),
});
if (second.status === 200) {
// Только если этот вызов всё ещё актуален — иначе зомби-цикл
// стирает баннер свежей мутации (#1242).
if (!signal.aborted) {
setFetchingState(null);
}
return second.body as ParcelAnalysis;
}
// 202 race → fall through and poll /fetch-status again.
continue;
}
if (status.status === "not_in_nspd") {
if (!signal.aborted) setFetchingState(null);
throw new HTTPError(
404,
status,
status.error_msg ?? "Кадастровый номер не найден в НСПД",
);
}
if (status.status === "failed") {
if (!signal.aborted) setFetchingState(null);
throw new HTTPError(
503,
status,
status.error_msg ?? "НСПД временно недоступен",
);
}
if (status.status === "invalid_format") {
if (!signal.aborted) setFetchingState(null);
throw new HTTPError(
400,
status,
status.error_msg ?? "Неверный формат кадастрового номера",
);
}
if (status.status === "fetching") {
// continue polling
continue;
}
// Неизвестный status (#1485): рассинхрон фронт/бэк — опечатка или
// новый/переименованный статус вне закрытого union FetchStatus.
// Бросаем явную ошибку вместо молчаливого polling до 2-мин таймаута
// (что вводило бы в заблуждение «загрузка слишком долгая»).
if (!signal.aborted) setFetchingState(null);
throw new Error(`Неизвестный статус загрузки: ${status.status}`);
}
// Polling exhausted (2 min)
if (!signal.aborted) setFetchingState(null);
throw new Error(
"Загрузка длится слишком долго (>2 мин). Попробуйте позже.",
);
}
throw new Error(`Unexpected response status: ${first.status}`);
},
});
const cancel = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
setFetchingState(null);
mutation.reset();
}, [mutation]);
return {
...mutation,
fetchingState,
cancel,
};
}