fix(site-finder): §4.1 «Применить» действительно применяет веса POI (#2790) #2810
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 { Section6Forecast } from "@/components/site-finder/analysis/Section6Forecast";
|
||||||
import { Section7Concept } from "@/components/site-finder/analysis/Section7Concept";
|
import { Section7Concept } from "@/components/site-finder/analysis/Section7Concept";
|
||||||
import { SectionAlternatives } from "@/components/site-finder/analysis/SectionAlternatives";
|
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 {
|
import type {
|
||||||
ParcelAnalysis,
|
ParcelAnalysis,
|
||||||
PendingConceptProgram,
|
PendingConceptProgram,
|
||||||
|
|
@ -29,7 +34,40 @@ interface Props {
|
||||||
|
|
||||||
// ── Page Content (client — needs TanStack Query) ───────────────────────────────
|
// ── Page Content (client — needs TanStack Query) ───────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обёртка над телом страницы: держит применённые в §4.1 POI-веса и кладёт их в
|
||||||
|
* контекст ВЫШЕ всех вызовов useParcelAnalyzeQuery (#2790). Своё состояние
|
||||||
|
* нельзя было оставить в теле: собственный вызов useParcelAnalyzeQuery читал бы
|
||||||
|
* контекст «сверху», то есть null, и страница разъехалась бы на два разных
|
||||||
|
* анализа — свой у шапки, свой у секций.
|
||||||
|
*
|
||||||
|
* null = веса не применяли → запрос как раньше, без тела.
|
||||||
|
*/
|
||||||
export function AnalysisPageContent({ cad }: Props) {
|
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 [horizon, setHorizon] = useState<number>(12);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
|
@ -216,8 +254,15 @@ export function AnalysisPageContent({ cad }: Props) {
|
||||||
{/* ── Группа «Стройка и рынок» ──────────────────────────────── */}
|
{/* ── Группа «Стройка и рынок» ──────────────────────────────── */}
|
||||||
<GroupDivider label="Стройка и рынок" />
|
<GroupDivider label="Стройка и рынок" />
|
||||||
|
|
||||||
{/* 4. Рынок и конкуренты — IMPLEMENTED in A7 */}
|
{/* 4. Рынок и конкуренты — IMPLEMENTED in A7. Веса POI из §4.1
|
||||||
<Section3SettingsAndCompetitors cad={cad} data={analysis} />
|
поднимаем сюда: «Применить» меняет ключ analyze-запроса → скор
|
||||||
|
пересчитывается по ползункам во ВСЕХ секциях (#2790). */}
|
||||||
|
<Section3SettingsAndCompetitors
|
||||||
|
cad={cad}
|
||||||
|
data={analysis}
|
||||||
|
weights={appliedWeights}
|
||||||
|
onWeightsApply={onWeightsApply}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 5. Атмосфера — IMPLEMENTED in A11 */}
|
{/* 5. Атмосфера — IMPLEMENTED in A11 */}
|
||||||
<Section5Atmosphere cad={cad} />
|
<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_LABELS,
|
||||||
POI_WEIGHT_MAX,
|
POI_WEIGHT_MAX,
|
||||||
POI_WEIGHT_MIN,
|
POI_WEIGHT_MIN,
|
||||||
|
SYSTEM_PROFILE_USER_ID,
|
||||||
useCreateProfile,
|
useCreateProfile,
|
||||||
useWeightProfiles,
|
useWeightProfiles,
|
||||||
type PoiCategoryKey,
|
type PoiCategoryKey,
|
||||||
|
|
@ -110,7 +111,18 @@ export function WeightProfilePanel({ currentWeights, onWeightsChange }: Props) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleApply() {
|
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 () => {
|
const handleSaveProfile = useCallback(async () => {
|
||||||
|
|
@ -258,6 +270,7 @@ export function WeightProfilePanel({ currentWeights, onWeightsChange }: Props) {
|
||||||
{profiles.map((p) => (
|
{profiles.map((p) => (
|
||||||
<option key={p.id} value={p.id}>
|
<option key={p.id} value={p.id}>
|
||||||
{p.profile_name}
|
{p.profile_name}
|
||||||
|
{p.user_id === SYSTEM_PROFILE_USER_ID ? " · пресет" : ""}
|
||||||
{p.is_default ? " ★" : ""}
|
{p.is_default ? " ★" : ""}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,10 @@ interface Props {
|
||||||
cad: string;
|
cad: string;
|
||||||
/** Full analysis data — used for Section 3.2/3.3 placeholders, competitors. */
|
/** Full analysis data — used for Section 3.2/3.3 placeholders, competitors. */
|
||||||
data: ParcelAnalysis;
|
data: ParcelAnalysis;
|
||||||
|
/** Уже применённые POI-веса; null = ничего не применяли (системные). */
|
||||||
|
weights: Record<PoiCategoryKey, number> | null;
|
||||||
|
/** «Применить» в панели весов — страница перезапрашивает analyze (#2790). */
|
||||||
|
onWeightsApply: (weights: Record<PoiCategoryKey, number>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FilterState {
|
interface FilterState {
|
||||||
|
|
@ -86,25 +90,18 @@ function FilterChip({ label, selected, onToggle }: ChipProps) {
|
||||||
function Section31Settings({
|
function Section31Settings({
|
||||||
filters,
|
filters,
|
||||||
onFiltersChange,
|
onFiltersChange,
|
||||||
|
weights,
|
||||||
|
onWeightsApply,
|
||||||
}: {
|
}: {
|
||||||
filters: FilterState;
|
filters: FilterState;
|
||||||
onFiltersChange: (f: FilterState) => void;
|
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">) {
|
function toggleChip(key: keyof Omit<FilterState, "radiusKm">) {
|
||||||
onFiltersChange({ ...filters, [key]: !filters[key] });
|
onFiltersChange({ ...filters, [key]: !filters[key] });
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleWeightsChange(
|
|
||||||
newWeights: Record<PoiCategoryKey, number>,
|
|
||||||
_profileId: number | null,
|
|
||||||
) {
|
|
||||||
setWeights(newWeights);
|
|
||||||
}
|
|
||||||
|
|
||||||
const chips: Array<{
|
const chips: Array<{
|
||||||
key: keyof Omit<FilterState, "radiusKm">;
|
key: keyof Omit<FilterState, "radiusKm">;
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -136,8 +133,8 @@ function Section31Settings({
|
||||||
margin: "4px 0 0",
|
margin: "4px 0 0",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Фильтры применяются к конкурентам локально — без повторного запроса к
|
Радиус и фильтры применяются к конкурентам локально. Веса POI —
|
||||||
бэкенду
|
пересчёт анализа на бэкенде по кнопке «Применить»
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -259,8 +256,8 @@ function Section31Settings({
|
||||||
Профиль весов POI
|
Профиль весов POI
|
||||||
</div>
|
</div>
|
||||||
<WeightProfilePanel
|
<WeightProfilePanel
|
||||||
currentWeights={weights}
|
currentWeights={weights ?? POI_DEFAULT_WEIGHTS}
|
||||||
onWeightsChange={handleWeightsChange}
|
onWeightsChange={onWeightsApply}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -769,7 +766,12 @@ function applyFilters(
|
||||||
|
|
||||||
// ── Section 3 wrapper ─────────────────────────────────────────────────────────
|
// ── Section 3 wrapper ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function Section3SettingsAndCompetitors({ cad, data }: Props) {
|
export function Section3SettingsAndCompetitors({
|
||||||
|
cad,
|
||||||
|
data,
|
||||||
|
weights,
|
||||||
|
onWeightsApply,
|
||||||
|
}: Props) {
|
||||||
const [filters, setFilters] = useState<FilterState>({
|
const [filters, setFilters] = useState<FilterState>({
|
||||||
radiusKm: 2,
|
radiusKm: 2,
|
||||||
onlyUnderConstruction: false,
|
onlyUnderConstruction: false,
|
||||||
|
|
@ -821,7 +823,12 @@ export function Section3SettingsAndCompetitors({ cad, data }: Props) {
|
||||||
<StageDetails>
|
<StageDetails>
|
||||||
{/* Sub-sections */}
|
{/* Sub-sections */}
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
|
<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 */}
|
{/* Competitor table — moved before 3.2/3.3 for context */}
|
||||||
{filteredCompetitors.length > 0 && (
|
{filteredCompetitors.length > 0 && (
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
* directly with a real AbortSignal and a per-URL `fetch` stub, under fake
|
* directly with a real AbortSignal and a per-URL `fetch` stub, under fake
|
||||||
* timers, and assert on abort behaviour + the happy path.
|
* timers, and assert on abort behaviour + the happy path.
|
||||||
*/
|
*/
|
||||||
|
import { renderHook } from "@testing-library/react";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
// ── Capture the options passed to useQuery ───────────────────────────────────
|
// ── 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-
|
* 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).
|
* 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
|
* Хук зовём через `renderHook`, а не напрямую: с #2790 он читает применённые
|
||||||
* the rules-of-hooks invariant does not apply to this call — disable locally.
|
* веса из `AnalyzeWeightsContext` (`useContext`), а вне рендера у React нет
|
||||||
|
* dispatcher'а → «Cannot read properties of null». `useQuery` по-прежнему
|
||||||
|
* замокан и просто записывает options; провайдера над хуком нет, значит
|
||||||
|
* контекст = null, то есть ровно тот случай «весов не применяли», который этот
|
||||||
|
* тест и гоняет.
|
||||||
*/
|
*/
|
||||||
function getQueryFn(): CapturedQueryOptions["queryFn"] {
|
function getQueryFn(): CapturedQueryOptions["queryFn"] {
|
||||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
renderHook(() => useParcelAnalyzeQuery(CAD, 12));
|
||||||
useParcelAnalyzeQuery(CAD, 12);
|
|
||||||
const options = captured.options;
|
const options = captured.options;
|
||||||
if (options === null) throw new Error("useQuery options not captured");
|
if (options === null) throw new Error("useQuery options not captured");
|
||||||
return options.queryFn;
|
return options.queryFn;
|
||||||
|
|
|
||||||
|
|
@ -27,14 +27,18 @@ export interface WeightProfileCreate {
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WeightProfileUpdate {
|
|
||||||
profile_name?: string;
|
|
||||||
weights?: Record<string, number>;
|
|
||||||
is_default?: boolean;
|
|
||||||
description?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
// ── 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.
|
// ALLOWED_CATEGORIES — mirrors backend weight_profiles.py ALLOWED_CATEGORIES.
|
||||||
// Keep in sync with backend; source of truth is `_POI_WEIGHTS` in parcels.py.
|
// 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 ─────────────────────────────────────────────────────────────────────
|
// ── 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) {
|
export function useWeightProfiles(userId: string) {
|
||||||
return useQuery<WeightProfile[]>({
|
return useQuery<WeightProfile[]>({
|
||||||
queryKey: ["weight-profiles", userId],
|
queryKey: ["weight-profiles", userId],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
apiFetch<WeightProfile[]>(
|
apiFetch<WeightProfile[]>(
|
||||||
`${BASE_PATH}?user_id=${encodeURIComponent(userId)}`,
|
`${BASE_PATH}?user_id=${encodeURIComponent(userId)}&include_system=true`,
|
||||||
),
|
),
|
||||||
enabled: !!userId,
|
enabled: !!userId,
|
||||||
});
|
});
|
||||||
|
|
@ -132,35 +143,9 @@ export function useCreateProfile() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Update an existing weight profile by id. */
|
// useUpdateProfile / useDeleteProfile здесь больше нет (#2790 п.3). Их не звали
|
||||||
export function useUpdateProfile(userId: string, profileId: number) {
|
// ниоткуда: в UI есть список и создание, кнопок «переименовать» / «удалить» нет.
|
||||||
const qc = useQueryClient();
|
// Спрос за 3 месяца по проду: 1 профиль на всю базу (`admin`, создан 15.05.2026,
|
||||||
return useMutation<WeightProfile, Error, WeightProfileUpdate>({
|
// updated_at = created_at) + 3 системных пресета — ни одного изменения и ни
|
||||||
mutationFn: (payload) =>
|
// одной попытки удаления. PUT/DELETE-эндпоинты живы и покрыты тестами бэкенда;
|
||||||
apiFetch<WeightProfile>(
|
// понадобится UI — хуки вернутся из истории (мертвее они там не станут).
|
||||||
`${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] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||||
|
import { createContext, useContext } from "react";
|
||||||
import { HTTPError, apiFetch, apiFetchWithStatus } from "@/lib/api";
|
import { HTTPError, apiFetch, apiFetchWithStatus } from "@/lib/api";
|
||||||
import { abortableSleep } from "@/lib/abortableSleep";
|
import { abortableSleep } from "@/lib/abortableSleep";
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -503,9 +504,36 @@ export interface PoiScoreResponse {
|
||||||
const ANALYZE_POLL_INTERVAL_MS = 2000;
|
const ANALYZE_POLL_INTERVAL_MS = 2000;
|
||||||
const ANALYZE_POLL_MAX_ITERATIONS = 60; // 60 × 2s = 2 min hard cap
|
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) {
|
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({
|
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
|
// TanStack Query v5 passes an AbortSignal in the queryFn context; it aborts
|
||||||
// on unmount and whenever the queryKey changes (смена cad/horizon). Thread
|
// on unmount and whenever the queryKey changes (смена cad/horizon). Thread
|
||||||
// it through the POST/GET fetches and check it before each poll iteration so
|
// 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,
|
cad,
|
||||||
)}/analyze?horizon=${horizon}`;
|
)}/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
|
// First request — POST /analyze. apiFetchWithStatus surfaces the 202
|
||||||
// Accepted code instead of treating it as a successful payload.
|
// Accepted code instead of treating it as a successful payload.
|
||||||
const first = await apiFetchWithStatus<
|
const first = await apiFetchWithStatus<
|
||||||
ParcelAnalyzeResponse | AnalyzeAcceptedResponse
|
ParcelAnalyzeResponse | AnalyzeAcceptedResponse
|
||||||
>(analyzeUrl, { method: "POST", signal });
|
>(analyzeUrl, analyzeInit);
|
||||||
|
|
||||||
// 200 → geometry was cached, full analysis is ready.
|
// 200 → geometry was cached, full analysis is ready.
|
||||||
if (first.status === 200) {
|
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).
|
// rather than returning the stub (symmetry with the first request).
|
||||||
const second = await apiFetchWithStatus<
|
const second = await apiFetchWithStatus<
|
||||||
ParcelAnalyzeResponse | AnalyzeAcceptedResponse
|
ParcelAnalyzeResponse | AnalyzeAcceptedResponse
|
||||||
>(analyzeUrl, { method: "POST", signal });
|
>(analyzeUrl, analyzeInit);
|
||||||
if (second.status === 200) {
|
if (second.status === 200) {
|
||||||
return second.body as ParcelAnalyzeResponse;
|
return second.body as ParcelAnalyzeResponse;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue