From 12bc9f346f6959eb5e4a0672a418c88d4f2d02d8 Mon Sep 17 00:00:00 2001
From: bot-backend
Date: Mon, 10 Aug 2026 13:29:54 +0500
Subject: [PATCH] =?UTF-8?q?fix(site-finder):=20=C2=A74.1=20=C2=AB=D0=9F?=
=?UTF-8?q?=D1=80=D0=B8=D0=BC=D0=B5=D0=BD=D0=B8=D1=82=D1=8C=C2=BB=20=D0=B4?=
=?UTF-8?q?=D0=B5=D0=B9=D1=81=D1=82=D0=B2=D0=B8=D1=82=D0=B5=D0=BB=D1=8C?=
=?UTF-8?q?=D0=BD=D0=BE=20=D0=BF=D1=80=D0=B8=D0=BC=D0=B5=D0=BD=D1=8F=D0=B5?=
=?UTF-8?q?=D1=82=20=D0=B2=D0=B5=D1=81=D0=B0=20POI=20(#2790)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Состояние ползунков было write-only: `Section31Settings` держал его у себя и
отдавал обратно в ту же панель, до /analyze оно не доезжало. Пользователь
двигал веса, жал «Применить» и получал ТОТ ЖЕ скор по системным весам —
проверено на проде: park 1.8→3.0, tram −0.5→−2.0, скор 18.91 до и после, за
две минуты ни одного нового POST /analyze.
Веса подняты на страницу и уходят в analyze через `AnalyzeWeightsContext`, а не
пропом: `useParcelAnalyzeQuery` зовут шесть мест (§1, §2, §4, §5, страница,
/ptica) на общем ключе кэша. Проп, забытый в одной секции, развёл бы ключи —
половина страницы считала бы по одним весам, половина по другим, и дорогой
analyze ушёл бы дважды. Контекст держит всех потребителей ключа на одном
значении по построению.
Веса уходят inline, а не через profile_id: тело запроса равно ползункам, ответ
рапортует source="inline". Прод-проверка: park 3.0 / tram −2.0 → 18.91 → 17.95.
Заодно (#2790 п.2): системные пресеты (Эконом / Комфорт / Бизнес) лежали в
проде с 16.05.2026 и не были видны никому — фронт не слал include_system.
Просто включить его было нельзя: пресет с user_id='__system__', выбранный как
profile_id, resolve_weights() ищет в области владельца, не находит и молча
берёт дефолты, отвечая source="profile" (#2782). Поэтому пресет теперь не
адресуется по profile_id — его веса уходят inline. Бэкенд не тронут.
Прод-проверка: «Бизнес» → inline metro_stop 2.5 / shop_mall 2.0 → скор 13.13.
П.3: useUpdateProfile / useDeleteProfile удалены. Их не звали ниоткуда, а
спрос за три месяца — 1 профиль на всю базу (admin, updated_at = created_at):
ни одного изменения, ни одной попытки удаления. Эндпоинты живы и покрыты
тестами бэкенда.
Refs #2790
---
.../analysis/[cad]/AnalysisPageContent.tsx | 51 +++++-
.../AnalysisPageContent.weights.test.tsx | 159 ++++++++++++++++++
.../site-finder/WeightProfilePanel.tsx | 15 +-
.../Section3SettingsAndCompetitors.tsx | 41 +++--
.../__tests__/useParcelAnalyzeQuery.test.ts | 12 +-
frontend/src/lib/api/weightProfiles.ts | 67 +++-----
frontend/src/lib/site-finder-api.ts | 42 ++++-
7 files changed, 318 insertions(+), 69 deletions(-)
create mode 100644 frontend/src/app/site-finder/analysis/[cad]/__tests__/AnalysisPageContent.weights.test.tsx
diff --git a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx
index 03be7b0d..41cd3e4d 100644
--- a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx
+++ b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx
@@ -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 | null>(null);
+
+ return (
+
+
+
+ );
+}
+
+function AnalysisPageBody({
+ cad,
+ appliedWeights,
+ onWeightsApply,
+}: Props & {
+ appliedWeights: Record | null;
+ onWeightsApply: (weights: Record) => void;
+}) {
const [horizon, setHorizon] = useState(12);
const queryClient = useQueryClient();
@@ -216,8 +254,15 @@ export function AnalysisPageContent({ cad }: Props) {
{/* ── Группа «Стройка и рынок» ──────────────────────────────── */}
- {/* 4. Рынок и конкуренты — IMPLEMENTED in A7 */}
-
+ {/* 4. Рынок и конкуренты — IMPLEMENTED in A7. Веса POI из §4.1
+ поднимаем сюда: «Применить» меняет ключ analyze-запроса → скор
+ пересчитывается по ползункам во ВСЕХ секциях (#2790). */}
+
{/* 5. Атмосфера — IMPLEMENTED in A11 */}
diff --git a/frontend/src/app/site-finder/analysis/[cad]/__tests__/AnalysisPageContent.weights.test.tsx b/frontend/src/app/site-finder/analysis/[cad]/__tests__/AnalysisPageContent.weights.test.tsx
new file mode 100644
index 00000000..4cc7e35e
--- /dev/null
+++ b/frontend/src/app/site-finder/analysis/[cad]/__tests__/AnalysisPageContent.weights.test.tsx
@@ -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 | undefined> = [];
+
+const fetchMock = vi.fn();
+
+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)
+ : 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(
+
+
+ ,
+ );
+}
+
+/** Ползунок конкретной категории по подписи строки в панели весов. */
+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;
+ expect(applied.park).toBe(3);
+ expect(applied.tram_stop).toBe(-2);
+ // Нетронутые категории уходят как есть — бэкенд мержит поверх системных,
+ // но панель отправляет полный набор, чтобы ответ совпадал с ползунками.
+ expect(applied.school).toBe(1.5);
+ });
+});
diff --git a/frontend/src/components/site-finder/WeightProfilePanel.tsx b/frontend/src/components/site-finder/WeightProfilePanel.tsx
index 60fcbe5b..5d5731f5 100644
--- a/frontend/src/components/site-finder/WeightProfilePanel.tsx
+++ b/frontend/src/components/site-finder/WeightProfilePanel.tsx
@@ -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) => (
))}
diff --git a/frontend/src/components/site-finder/analysis/Section3SettingsAndCompetitors.tsx b/frontend/src/components/site-finder/analysis/Section3SettingsAndCompetitors.tsx
index 670fb483..6c616440 100644
--- a/frontend/src/components/site-finder/analysis/Section3SettingsAndCompetitors.tsx
+++ b/frontend/src/components/site-finder/analysis/Section3SettingsAndCompetitors.tsx
@@ -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 | null;
+ /** «Применить» в панели весов — страница перезапрашивает analyze (#2790). */
+ onWeightsApply: (weights: Record) => 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 | null;
+ onWeightsApply: (weights: Record) => void;
}) {
- const [weights, setWeights] = useState>(
- () => ({ ...POI_DEFAULT_WEIGHTS }),
- );
-
function toggleChip(key: keyof Omit) {
onFiltersChange({ ...filters, [key]: !filters[key] });
}
- function handleWeightsChange(
- newWeights: Record,
- _profileId: number | null,
- ) {
- setWeights(newWeights);
- }
-
const chips: Array<{
key: keyof Omit;
label: string;
@@ -136,8 +133,8 @@ function Section31Settings({
margin: "4px 0 0",
}}
>
- Фильтры применяются к конкурентам локально — без повторного запроса к
- бэкенду
+ Радиус и фильтры применяются к конкурентам локально. Веса POI —
+ пересчёт анализа на бэкенде по кнопке «Применить»
@@ -259,8 +256,8 @@ function Section31Settings({
Профиль весов POI
@@ -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({
radiusKm: 2,
onlyUnderConstruction: false,
@@ -821,7 +823,12 @@ export function Section3SettingsAndCompetitors({ cad, data }: Props) {
{/* Sub-sections */}
-
+
{/* Competitor table — moved before 3.2/3.3 for context */}
{filteredCompetitors.length > 0 && (
diff --git a/frontend/src/lib/__tests__/useParcelAnalyzeQuery.test.ts b/frontend/src/lib/__tests__/useParcelAnalyzeQuery.test.ts
index f84f2033..81f0e074 100644
--- a/frontend/src/lib/__tests__/useParcelAnalyzeQuery.test.ts
+++ b/frontend/src/lib/__tests__/useParcelAnalyzeQuery.test.ts
@@ -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;
diff --git a/frontend/src/lib/api/weightProfiles.ts b/frontend/src/lib/api/weightProfiles.ts
index 4aa28bd7..1534e8ce 100644
--- a/frontend/src/lib/api/weightProfiles.ts
+++ b/frontend/src/lib/api/weightProfiles.ts
@@ -27,14 +27,18 @@ export interface WeightProfileCreate {
description?: string | null;
}
-export interface WeightProfileUpdate {
- profile_name?: string;
- weights?: Record;
- 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({
queryKey: ["weight-profiles", userId],
queryFn: () =>
apiFetch(
- `${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({
- mutationFn: (payload) =>
- apiFetch(
- `${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({
- mutationFn: (profileId) =>
- apiFetch(
- `${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 — хуки вернутся из истории (мертвее они там не станут).
diff --git a/frontend/src/lib/site-finder-api.ts b/frontend/src/lib/site-finder-api.ts
index ca64a76d..ebd0542b 100644
--- a/frontend/src/lib/site-finder-api.ts
+++ b/frontend/src/lib/site-finder-api.ts
@@ -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 | 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;
}
--
2.45.3