fix(site-finder): §4.1 «Применить» действительно применяет веса POI (#2790) (#2810)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 3m21s
Deploy / deploy (push) Successful in 1m13s

This commit is contained in:
bot-backend 2026-08-10 08:39:59 +00:00
parent 12c189ac27
commit 84a65d40dd
7 changed files with 318 additions and 69 deletions

View file

@ -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} />

View file

@ -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);
});
});

View file

@ -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>
))}

View file

@ -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 && (

View file

@ -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;

View file

@ -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 — хуки вернутся из истории (мертвее они там не станут).

View file

@ -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;
}