Многоагентный аудит + имплементация: один воркер на файл, точечные правки. Верификация: 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>
253 lines
9.7 KiB
TypeScript
253 lines
9.7 KiB
TypeScript
"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,
|
||
};
|
||
}
|