gendesign/frontend/src/hooks/useSiteAnalysis.ts
bot-backend 209e4e145f
All checks were successful
Deploy / changes (push) Successful in 10s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 3m43s
Deploy / deploy (push) Successful in 1m23s
fix(site-finder): вернуть доступ к профилям весов — владелец из сессии (#2782) (#2788)
2026-08-07 10:22:55 +00:00

277 lines
11 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;
}
/**
* Собрать options для POST /analyze из состояния панели весов POI.
*
* 🔴 `profileId` уходит на бэкенд ТОЛЬКО в паре с `profileUserId`. Причина не
* стилистическая: `resolve_weights()` ищет профиль как `get_profile(db, user_id,
* profile_id)` — при `user_id=None` условие `profile_id is not None and user_id is
* not None` не выполняется, и веса тихо падают на системные. Ответ при этом
* рапортует `weights_profile.source = "profile"`, то есть врёт (проверено на проде
* #2782: `profile_id=1` без `profile_user_id` → `tram_stop 0.5` вместо `0.4` из
* профиля). Юзер бы видел ползунки профиля и score, посчитанный по другим весам.
*
* Когда пользователь неизвестен (dev без Caddy: /api/v1/me → 401) — шлём inline
* `weights`. Они всегда равны ползункам панели, так что расхождения нет.
*/
export function buildAnalyzeOptions(
weights: Record<string, number>,
profileId: number | null,
profileUserId: string,
): AnalyzeOptions {
if (profileId != null && profileUserId) {
return { profileId, profileUserId };
}
if (profileUserId) {
return { profileUserId, weights };
}
return { weights };
}
/**
* 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,
};
}