This commit is contained in:
parent
12c189ac27
commit
84a65d40dd
7 changed files with 318 additions and 69 deletions
|
|
@ -15,7 +15,12 @@ import { Section5Atmosphere } from "@/components/site-finder/analysis/Section5At
|
|||
import { Section6Forecast } from "@/components/site-finder/analysis/Section6Forecast";
|
||||
import { Section7Concept } from "@/components/site-finder/analysis/Section7Concept";
|
||||
import { SectionAlternatives } from "@/components/site-finder/analysis/SectionAlternatives";
|
||||
import { adaptEgrn, useParcelAnalyzeQuery } from "@/lib/site-finder-api";
|
||||
import {
|
||||
AnalyzeWeightsContext,
|
||||
adaptEgrn,
|
||||
useParcelAnalyzeQuery,
|
||||
} from "@/lib/site-finder-api";
|
||||
import type { PoiCategoryKey } from "@/lib/api/weightProfiles";
|
||||
import type {
|
||||
ParcelAnalysis,
|
||||
PendingConceptProgram,
|
||||
|
|
@ -29,7 +34,40 @@ interface Props {
|
|||
|
||||
// ── Page Content (client — needs TanStack Query) ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Обёртка над телом страницы: держит применённые в §4.1 POI-веса и кладёт их в
|
||||
* контекст ВЫШЕ всех вызовов useParcelAnalyzeQuery (#2790). Своё состояние
|
||||
* нельзя было оставить в теле: собственный вызов useParcelAnalyzeQuery читал бы
|
||||
* контекст «сверху», то есть null, и страница разъехалась бы на два разных
|
||||
* анализа — свой у шапки, свой у секций.
|
||||
*
|
||||
* null = веса не применяли → запрос как раньше, без тела.
|
||||
*/
|
||||
export function AnalysisPageContent({ cad }: Props) {
|
||||
const [appliedWeights, setAppliedWeights] = useState<Record<
|
||||
PoiCategoryKey,
|
||||
number
|
||||
> | null>(null);
|
||||
|
||||
return (
|
||||
<AnalyzeWeightsContext.Provider value={appliedWeights}>
|
||||
<AnalysisPageBody
|
||||
cad={cad}
|
||||
appliedWeights={appliedWeights}
|
||||
onWeightsApply={setAppliedWeights}
|
||||
/>
|
||||
</AnalyzeWeightsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function AnalysisPageBody({
|
||||
cad,
|
||||
appliedWeights,
|
||||
onWeightsApply,
|
||||
}: Props & {
|
||||
appliedWeights: Record<PoiCategoryKey, number> | null;
|
||||
onWeightsApply: (weights: Record<PoiCategoryKey, number>) => void;
|
||||
}) {
|
||||
const [horizon, setHorizon] = useState<number>(12);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
|
@ -216,8 +254,15 @@ export function AnalysisPageContent({ cad }: Props) {
|
|||
{/* ── Группа «Стройка и рынок» ──────────────────────────────── */}
|
||||
<GroupDivider label="Стройка и рынок" />
|
||||
|
||||
{/* 4. Рынок и конкуренты — IMPLEMENTED in A7 */}
|
||||
<Section3SettingsAndCompetitors cad={cad} data={analysis} />
|
||||
{/* 4. Рынок и конкуренты — IMPLEMENTED in A7. Веса POI из §4.1
|
||||
поднимаем сюда: «Применить» меняет ключ analyze-запроса → скор
|
||||
пересчитывается по ползункам во ВСЕХ секциях (#2790). */}
|
||||
<Section3SettingsAndCompetitors
|
||||
cad={cad}
|
||||
data={analysis}
|
||||
weights={appliedWeights}
|
||||
onWeightsApply={onWeightsApply}
|
||||
/>
|
||||
|
||||
{/* 5. Атмосфера — IMPLEMENTED in A11 */}
|
||||
<Section5Atmosphere cad={cad} />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,159 @@
|
|||
/**
|
||||
* #2790 п.1 — «Применить» у весов POI в §4.1 ничего не применяло.
|
||||
*
|
||||
* Состояние весов жило в `Section31Settings` и читалось только обратно в ту же
|
||||
* панель: до `/analyze` оно не доезжало никогда (слова `weights` в
|
||||
* AnalysisPageContent не было вовсе). Пользователь двигал ползунки, жал
|
||||
* «Применить» и получал ТОТ ЖЕ скор, посчитанный по системным весам.
|
||||
*
|
||||
* Тест идёт живым путём: рендерит настоящую страницу с настоящей §4.1 и
|
||||
* настоящим `useParcelAnalyzeQuery` (замокан только тяжёлый обвес — карты,
|
||||
* прогноз, концепция) и смотрит, что уходит в сеть. На коде до фикса второй
|
||||
* POST /analyze не случается вообще → красный.
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { AnalysisPageContent } from "../AnalysisPageContent";
|
||||
|
||||
// Тяжёлые секции не участвуют в контракте «ползунки → запрос»: они тянут
|
||||
// Leaflet / ECharts / собственные poll-запросы. §3 (настройки + панель весов) —
|
||||
// НАСТОЯЩАЯ, как и useParcelAnalyzeQuery: они и есть предмет теста.
|
||||
vi.mock("@/components/site-finder/ChatDock", () => ({ ChatDock: () => null }));
|
||||
vi.mock("@/components/site-finder/GateVerdictBanner", () => ({
|
||||
GateVerdictBanner: () => null,
|
||||
}));
|
||||
vi.mock("@/components/site-finder/HorizonSelector", () => ({
|
||||
HorizonSelector: () => null,
|
||||
}));
|
||||
vi.mock("@/components/site-finder/analysis/Section1ParcelInfo", () => ({
|
||||
Section1ParcelInfo: () => null,
|
||||
}));
|
||||
vi.mock("@/components/site-finder/analysis/Section2NetworksUtilities", () => ({
|
||||
Section2NetworksUtilities: () => null,
|
||||
}));
|
||||
vi.mock("@/components/site-finder/analysis/Section4Estimate", () => ({
|
||||
Section4Estimate: () => null,
|
||||
}));
|
||||
vi.mock("@/components/site-finder/analysis/Section5Atmosphere", () => ({
|
||||
Section5Atmosphere: () => null,
|
||||
}));
|
||||
vi.mock("@/components/site-finder/analysis/Section6Forecast", () => ({
|
||||
Section6Forecast: () => null,
|
||||
}));
|
||||
vi.mock("@/components/site-finder/analysis/Section7Concept", () => ({
|
||||
Section7Concept: () => null,
|
||||
}));
|
||||
vi.mock("@/components/site-finder/analysis/SectionAlternatives", () => ({
|
||||
SectionAlternatives: () => null,
|
||||
}));
|
||||
vi.mock("@/components/site-finder/BestLayoutsBlock", () => ({
|
||||
BestLayoutsBlock: () => null,
|
||||
}));
|
||||
|
||||
const CAD = "66:41:0702017:131";
|
||||
|
||||
const ANALYSIS = {
|
||||
cad_num: CAD,
|
||||
score: 18.91,
|
||||
district: { district_name: "Чкаловский" },
|
||||
egrn: null,
|
||||
competitors: [],
|
||||
};
|
||||
|
||||
/** Тела всех POST /analyze в порядке отправки. undefined = запрос без тела. */
|
||||
const analyzeBodies: Array<Record<string, unknown> | undefined> = [];
|
||||
|
||||
const fetchMock = vi.fn<typeof fetch>();
|
||||
|
||||
function jsonResponse(body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
analyzeBodies.length = 0;
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockImplementation(async (input, init) => {
|
||||
const url = typeof input === "string" ? input : String(input);
|
||||
if (url.includes("/analyze")) {
|
||||
const raw = init?.body;
|
||||
analyzeBodies.push(
|
||||
typeof raw === "string"
|
||||
? (JSON.parse(raw) as Record<string, unknown>)
|
||||
: undefined,
|
||||
);
|
||||
return jsonResponse(ANALYSIS);
|
||||
}
|
||||
if (url.includes("/api/v1/me")) {
|
||||
return jsonResponse({
|
||||
username: "admin",
|
||||
role: "admin",
|
||||
allowed_paths: ["/**"],
|
||||
deny_paths: [],
|
||||
});
|
||||
}
|
||||
if (url.includes("/weight-profiles")) {
|
||||
return jsonResponse([]);
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function renderPage() {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<AnalysisPageContent cad={CAD} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/** Ползунок конкретной категории по подписи строки в панели весов. */
|
||||
function sliderFor(label: string): HTMLInputElement {
|
||||
const row = screen.getByText(label).closest("div");
|
||||
if (!row) throw new Error(`не нашёл строку ползунка «${label}»`);
|
||||
const input = row.querySelector('input[type="range"]');
|
||||
if (!input) throw new Error(`в строке «${label}» нет ползунка`);
|
||||
return input as HTMLInputElement;
|
||||
}
|
||||
|
||||
describe("§4.1 «Применить» доносит веса до /analyze (#2790)", () => {
|
||||
it("отправляет ползунки в тело повторного analyze", async () => {
|
||||
renderPage();
|
||||
|
||||
// Первичный анализ — без весов (ничего не применяли): тело не шлём вовсе,
|
||||
// бэкенд считает по системным. Это же и baseline для «стало другим».
|
||||
await waitFor(() => expect(analyzeBodies.length).toBe(1));
|
||||
expect(analyzeBodies[0]).toBeUndefined();
|
||||
|
||||
fireEvent.click(await screen.findByText("POI Веса"));
|
||||
fireEvent.change(sliderFor("Парки"), { target: { value: "3" } });
|
||||
fireEvent.change(sliderFor("Трамвайные ост. (−)"), {
|
||||
target: { value: "-2" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Применить" }));
|
||||
|
||||
// Главное утверждение: analyze уходит ЗАНОВО и несёт ровно те веса, что
|
||||
// выставлены ползунками. До фикса второго запроса не было — красный здесь.
|
||||
await waitFor(() => expect(analyzeBodies.length).toBe(2));
|
||||
const applied = analyzeBodies[1]?.weights as Record<string, number>;
|
||||
expect(applied.park).toBe(3);
|
||||
expect(applied.tram_stop).toBe(-2);
|
||||
// Нетронутые категории уходят как есть — бэкенд мержит поверх системных,
|
||||
// но панель отправляет полный набор, чтобы ответ совпадал с ползунками.
|
||||
expect(applied.school).toBe(1.5);
|
||||
});
|
||||
});
|
||||
|
|
@ -10,6 +10,7 @@ import {
|
|||
POI_LABELS,
|
||||
POI_WEIGHT_MAX,
|
||||
POI_WEIGHT_MIN,
|
||||
SYSTEM_PROFILE_USER_ID,
|
||||
useCreateProfile,
|
||||
useWeightProfiles,
|
||||
type PoiCategoryKey,
|
||||
|
|
@ -110,7 +111,18 @@ export function WeightProfilePanel({ currentWeights, onWeightsChange }: Props) {
|
|||
}
|
||||
|
||||
function handleApply() {
|
||||
onWeightsChange({ ...draft }, selectedProfileId);
|
||||
// Системный пресет не адресуем через profile_id: resolve_weights() ищет
|
||||
// профиль в области ВЛАДЕЛЬЦА, а владелец пресета — `__system__`, не
|
||||
// текущий пользователь. Бэкенд его не найдёт, тихо возьмёт дефолтные веса и
|
||||
// отрапортует `weights_profile.source = "profile"` (#2782). Поэтому для
|
||||
// пресета отдаём profileId = null — вызывающая сторона пошлёт inline-веса,
|
||||
// а они ровно те, что на ползунках.
|
||||
const selected = profiles.find((p) => p.id === selectedProfileId) ?? null;
|
||||
const addressableId =
|
||||
selected && selected.user_id !== SYSTEM_PROFILE_USER_ID
|
||||
? selected.id
|
||||
: null;
|
||||
onWeightsChange({ ...draft }, addressableId);
|
||||
}
|
||||
|
||||
const handleSaveProfile = useCallback(async () => {
|
||||
|
|
@ -258,6 +270,7 @@ export function WeightProfilePanel({ currentWeights, onWeightsChange }: Props) {
|
|||
{profiles.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.profile_name}
|
||||
{p.user_id === SYSTEM_PROFILE_USER_ID ? " · пресет" : ""}
|
||||
{p.is_default ? " ★" : ""}
|
||||
</option>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ interface Props {
|
|||
cad: string;
|
||||
/** Full analysis data — used for Section 3.2/3.3 placeholders, competitors. */
|
||||
data: ParcelAnalysis;
|
||||
/** Уже применённые POI-веса; null = ничего не применяли (системные). */
|
||||
weights: Record<PoiCategoryKey, number> | null;
|
||||
/** «Применить» в панели весов — страница перезапрашивает analyze (#2790). */
|
||||
onWeightsApply: (weights: Record<PoiCategoryKey, number>) => void;
|
||||
}
|
||||
|
||||
interface FilterState {
|
||||
|
|
@ -86,25 +90,18 @@ function FilterChip({ label, selected, onToggle }: ChipProps) {
|
|||
function Section31Settings({
|
||||
filters,
|
||||
onFiltersChange,
|
||||
weights,
|
||||
onWeightsApply,
|
||||
}: {
|
||||
filters: FilterState;
|
||||
onFiltersChange: (f: FilterState) => void;
|
||||
weights: Record<PoiCategoryKey, number> | null;
|
||||
onWeightsApply: (weights: Record<PoiCategoryKey, number>) => void;
|
||||
}) {
|
||||
const [weights, setWeights] = useState<Record<PoiCategoryKey, number>>(
|
||||
() => ({ ...POI_DEFAULT_WEIGHTS }),
|
||||
);
|
||||
|
||||
function toggleChip(key: keyof Omit<FilterState, "radiusKm">) {
|
||||
onFiltersChange({ ...filters, [key]: !filters[key] });
|
||||
}
|
||||
|
||||
function handleWeightsChange(
|
||||
newWeights: Record<PoiCategoryKey, number>,
|
||||
_profileId: number | null,
|
||||
) {
|
||||
setWeights(newWeights);
|
||||
}
|
||||
|
||||
const chips: Array<{
|
||||
key: keyof Omit<FilterState, "radiusKm">;
|
||||
label: string;
|
||||
|
|
@ -136,8 +133,8 @@ function Section31Settings({
|
|||
margin: "4px 0 0",
|
||||
}}
|
||||
>
|
||||
Фильтры применяются к конкурентам локально — без повторного запроса к
|
||||
бэкенду
|
||||
Радиус и фильтры применяются к конкурентам локально. Веса POI —
|
||||
пересчёт анализа на бэкенде по кнопке «Применить»
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -259,8 +256,8 @@ function Section31Settings({
|
|||
Профиль весов POI
|
||||
</div>
|
||||
<WeightProfilePanel
|
||||
currentWeights={weights}
|
||||
onWeightsChange={handleWeightsChange}
|
||||
currentWeights={weights ?? POI_DEFAULT_WEIGHTS}
|
||||
onWeightsChange={onWeightsApply}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -769,7 +766,12 @@ function applyFilters(
|
|||
|
||||
// ── Section 3 wrapper ─────────────────────────────────────────────────────────
|
||||
|
||||
export function Section3SettingsAndCompetitors({ cad, data }: Props) {
|
||||
export function Section3SettingsAndCompetitors({
|
||||
cad,
|
||||
data,
|
||||
weights,
|
||||
onWeightsApply,
|
||||
}: Props) {
|
||||
const [filters, setFilters] = useState<FilterState>({
|
||||
radiusKm: 2,
|
||||
onlyUnderConstruction: false,
|
||||
|
|
@ -821,7 +823,12 @@ export function Section3SettingsAndCompetitors({ cad, data }: Props) {
|
|||
<StageDetails>
|
||||
{/* Sub-sections */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
|
||||
<Section31Settings filters={filters} onFiltersChange={setFilters} />
|
||||
<Section31Settings
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
weights={weights}
|
||||
onWeightsApply={onWeightsApply}
|
||||
/>
|
||||
|
||||
{/* Competitor table — moved before 3.2/3.3 for context */}
|
||||
{filteredCompetitors.length > 0 && (
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
* directly with a real AbortSignal and a per-URL `fetch` stub, under fake
|
||||
* timers, and assert on abort behaviour + the happy path.
|
||||
*/
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// ── Capture the options passed to useQuery ───────────────────────────────────
|
||||
|
|
@ -117,12 +118,15 @@ const CAD = "66:41:0701045:42";
|
|||
* polling queryFn. Reads `captured.options` via a fresh binding so TS control-
|
||||
* flow doesn't pin it (the hook mutates it opaquely through the mock).
|
||||
*
|
||||
* `useQuery` is fully mocked (it just records its options, no React state), so
|
||||
* the rules-of-hooks invariant does not apply to this call — disable locally.
|
||||
* Хук зовём через `renderHook`, а не напрямую: с #2790 он читает применённые
|
||||
* веса из `AnalyzeWeightsContext` (`useContext`), а вне рендера у React нет
|
||||
* dispatcher'а → «Cannot read properties of null». `useQuery` по-прежнему
|
||||
* замокан и просто записывает options; провайдера над хуком нет, значит
|
||||
* контекст = null, то есть ровно тот случай «весов не применяли», который этот
|
||||
* тест и гоняет.
|
||||
*/
|
||||
function getQueryFn(): CapturedQueryOptions["queryFn"] {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
useParcelAnalyzeQuery(CAD, 12);
|
||||
renderHook(() => useParcelAnalyzeQuery(CAD, 12));
|
||||
const options = captured.options;
|
||||
if (options === null) throw new Error("useQuery options not captured");
|
||||
return options.queryFn;
|
||||
|
|
|
|||
|
|
@ -27,14 +27,18 @@ export interface WeightProfileCreate {
|
|||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface WeightProfileUpdate {
|
||||
profile_name?: string;
|
||||
weights?: Record<string, number>;
|
||||
is_default?: boolean;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Владелец системных пресетов (Эконом / Комфорт / Бизнес) — mirrors
|
||||
* `SYSTEM_USER_ID` в backend/app/services/site_finder/weight_profiles.py.
|
||||
* Профили с этим user_id общие для всех и НЕ адресуемы через `profile_id`:
|
||||
* `resolve_weights()` ищет профиль в области владельца, у чужого пользователя
|
||||
* его не найдёт и молча вернёт системные веса с ответом `source="profile"`
|
||||
* (#2782). Их веса уходят в analyze inline — см. WeightProfilePanel.
|
||||
*/
|
||||
export const SYSTEM_PROFILE_USER_ID = "__system__";
|
||||
|
||||
// ALLOWED_CATEGORIES — mirrors backend weight_profiles.py ALLOWED_CATEGORIES.
|
||||
// Keep in sync with backend; source of truth is `_POI_WEIGHTS` in parcels.py.
|
||||
|
||||
|
|
@ -103,13 +107,20 @@ const BASE_PATH = "/api/v1/admin/site-finder/weight-profiles";
|
|||
|
||||
// ── Hooks ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** List all weight profiles for a given user_id. */
|
||||
/**
|
||||
* Профили пользователя + системные пресеты (#2790).
|
||||
*
|
||||
* `include_system=true` домешивает в конец списка три общих пресета (Эконом /
|
||||
* Комфорт / Бизнес, засеяны `data/sql/100_user_weight_profiles_default_seed.sql`).
|
||||
* Без него у пользователя без своих профилей дропдаун пустой — пресеты лежали в
|
||||
* проде с 16.05.2026 и не были видны никому.
|
||||
*/
|
||||
export function useWeightProfiles(userId: string) {
|
||||
return useQuery<WeightProfile[]>({
|
||||
queryKey: ["weight-profiles", userId],
|
||||
queryFn: () =>
|
||||
apiFetch<WeightProfile[]>(
|
||||
`${BASE_PATH}?user_id=${encodeURIComponent(userId)}`,
|
||||
`${BASE_PATH}?user_id=${encodeURIComponent(userId)}&include_system=true`,
|
||||
),
|
||||
enabled: !!userId,
|
||||
});
|
||||
|
|
@ -132,35 +143,9 @@ export function useCreateProfile() {
|
|||
});
|
||||
}
|
||||
|
||||
/** Update an existing weight profile by id. */
|
||||
export function useUpdateProfile(userId: string, profileId: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<WeightProfile, Error, WeightProfileUpdate>({
|
||||
mutationFn: (payload) =>
|
||||
apiFetch<WeightProfile>(
|
||||
`${BASE_PATH}/${profileId}?user_id=${encodeURIComponent(userId)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ["weight-profiles", userId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Delete a weight profile by id. Resolves on success (backend returns 204 No Content). */
|
||||
export function useDeleteProfile(userId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<void, Error, number>({
|
||||
mutationFn: (profileId) =>
|
||||
apiFetch<void>(
|
||||
`${BASE_PATH}/${profileId}?user_id=${encodeURIComponent(userId)}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ["weight-profiles", userId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
// useUpdateProfile / useDeleteProfile здесь больше нет (#2790 п.3). Их не звали
|
||||
// ниоткуда: в UI есть список и создание, кнопок «переименовать» / «удалить» нет.
|
||||
// Спрос за 3 месяца по проду: 1 профиль на всю базу (`admin`, создан 15.05.2026,
|
||||
// updated_at = created_at) + 3 системных пресета — ни одного изменения и ни
|
||||
// одной попытки удаления. PUT/DELETE-эндпоинты живы и покрыты тестами бэкенда;
|
||||
// понадобится UI — хуки вернутся из истории (мертвее они там не станут).
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
*/
|
||||
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { createContext, useContext } from "react";
|
||||
import { HTTPError, apiFetch, apiFetchWithStatus } from "@/lib/api";
|
||||
import { abortableSleep } from "@/lib/abortableSleep";
|
||||
import type {
|
||||
|
|
@ -503,9 +504,36 @@ export interface PoiScoreResponse {
|
|||
const ANALYZE_POLL_INTERVAL_MS = 2000;
|
||||
const ANALYZE_POLL_MAX_ITERATIONS = 60; // 60 × 2s = 2 min hard cap
|
||||
|
||||
/**
|
||||
* Применённые в §4.1 POI-веса (#2790). `null` = ничего не применяли → запрос
|
||||
* уходит без тела, как и раньше (бэкенд считает по системным весам).
|
||||
*
|
||||
* Почему контекст, а не проп: на странице анализа `useParcelAnalyzeQuery(cad)`
|
||||
* зовут ШЕСТЬ мест (§1, §2, §4, §5, сама страница, /ptica) — все они делят один
|
||||
* ключ кэша `["parcel-analyze", cad, horizon]` и один дорогой (10-30 c) запрос.
|
||||
* Если веса доедут только до части из них, ключи разойдутся: половина страницы
|
||||
* покажет скор по одним весам, половина по другим, и /analyze уйдёт дважды.
|
||||
* Контекст держит всех потребителей ключа на одном значении по построению —
|
||||
* забыть прокинуть проп в новую секцию нельзя.
|
||||
*/
|
||||
export const AnalyzeWeightsContext = createContext<Record<
|
||||
string,
|
||||
number
|
||||
> | null>(null);
|
||||
|
||||
export function useParcelAnalyzeQuery(cad: string, horizon: number = 12) {
|
||||
const weights = useContext(AnalyzeWeightsContext);
|
||||
// Стабильный кусок ключа: порядок ключей объекта не гарантирован, сортируем.
|
||||
// null (весов не применяли) оставляем null — ключ тогда совпадает с ключом до
|
||||
// #2790, кэш не сбрасывается на ровном месте.
|
||||
const weightsKey = weights
|
||||
? JSON.stringify(Object.entries(weights).sort())
|
||||
: null;
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["parcel-analyze", cad, horizon],
|
||||
// Префикс ["parcel-analyze", cad] сохранён: по нему инвалидируют custom-POI
|
||||
// мутации (useCustomPois) — они матчатся по префиксу, любой хвост подойдёт.
|
||||
queryKey: ["parcel-analyze", cad, horizon, weightsKey],
|
||||
// TanStack Query v5 passes an AbortSignal in the queryFn context; it aborts
|
||||
// on unmount and whenever the queryKey changes (смена cad/horizon). Thread
|
||||
// it through the POST/GET fetches and check it before each poll iteration so
|
||||
|
|
@ -522,11 +550,19 @@ export function useParcelAnalyzeQuery(cad: string, horizon: number = 12) {
|
|||
cad,
|
||||
)}/analyze?horizon=${horizon}`;
|
||||
|
||||
// Inline POI-веса (#201) из §4.1. Шлём именно inline, а не profile_id:
|
||||
// тело запроса == ползункам панели, и ответ рапортует source="inline" —
|
||||
// расхождению между показанными весами и посчитанным скором взяться
|
||||
// неоткуда (в отличие от profile_id, см. #2782).
|
||||
const analyzeInit: RequestInit = weights
|
||||
? { method: "POST", signal, body: JSON.stringify({ weights }) }
|
||||
: { method: "POST", signal };
|
||||
|
||||
// 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", signal });
|
||||
>(analyzeUrl, analyzeInit);
|
||||
|
||||
// 200 → geometry was cached, full analysis is ready.
|
||||
if (first.status === 200) {
|
||||
|
|
@ -553,7 +589,7 @@ export function useParcelAnalyzeQuery(cad: string, horizon: number = 12) {
|
|||
// rather than returning the stub (symmetry with the first request).
|
||||
const second = await apiFetchWithStatus<
|
||||
ParcelAnalyzeResponse | AnalyzeAcceptedResponse
|
||||
>(analyzeUrl, { method: "POST", signal });
|
||||
>(analyzeUrl, analyzeInit);
|
||||
if (second.status === 200) {
|
||||
return second.body as ParcelAnalyzeResponse;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue