All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m12s
CI / openapi-codegen-check (pull_request) Successful in 2m15s
Состояние ползунков было 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
151 lines
6.6 KiB
TypeScript
151 lines
6.6 KiB
TypeScript
"use client";
|
||
|
||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
|
||
import { apiFetch } from "@/lib/api";
|
||
|
||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||
// Manually typed to match backend Pydantic schemas (sub-PR 3).
|
||
// After PR #138 merges run `npm run codegen` to sync with generated openapi types.
|
||
|
||
export interface WeightProfile {
|
||
id: number;
|
||
user_id: string;
|
||
profile_name: string;
|
||
weights: Record<string, number>;
|
||
is_default: boolean;
|
||
description: string | null;
|
||
created_at: string;
|
||
updated_at: string;
|
||
}
|
||
|
||
export interface WeightProfileCreate {
|
||
user_id: string;
|
||
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.
|
||
|
||
export const POI_CATEGORIES = [
|
||
"school",
|
||
"kindergarten",
|
||
"pharmacy",
|
||
"hospital",
|
||
"shop_mall",
|
||
"shop_supermarket",
|
||
"shop_small",
|
||
"park",
|
||
"bus_stop",
|
||
"metro_stop",
|
||
"tram_stop",
|
||
] as const;
|
||
|
||
export type PoiCategoryKey = (typeof POI_CATEGORIES)[number];
|
||
|
||
// System defaults — mirrors _POI_WEIGHTS in backend/app/api/v1/parcels.py.
|
||
export const POI_DEFAULT_WEIGHTS: Record<PoiCategoryKey, number> = {
|
||
school: 1.5,
|
||
kindergarten: 1.5,
|
||
pharmacy: 0.8,
|
||
hospital: 0.6,
|
||
shop_mall: 1.2,
|
||
shop_supermarket: 1.0,
|
||
shop_small: 0.5,
|
||
park: 1.8,
|
||
bus_stop: 0.3,
|
||
metro_stop: 1.5,
|
||
tram_stop: -0.5,
|
||
};
|
||
|
||
export const POI_LABELS: Record<PoiCategoryKey, string> = {
|
||
school: "Школы",
|
||
kindergarten: "Детсады",
|
||
pharmacy: "Аптеки",
|
||
hospital: "Больницы",
|
||
shop_mall: "ТРЦ / Молы",
|
||
shop_supermarket: "Супермаркеты",
|
||
shop_small: "Магазины у дома",
|
||
park: "Парки",
|
||
bus_stop: "Автобусные ост.",
|
||
metro_stop: "Метро",
|
||
tram_stop: "Трамвайные ост. (−)",
|
||
};
|
||
|
||
// Weight range bounds — mirrors MIN_WEIGHT / MAX_WEIGHT in backend.
|
||
export const POI_WEIGHT_MIN = -2;
|
||
export const POI_WEIGHT_MAX = 3;
|
||
|
||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||
|
||
const BASE_PATH = "/api/v1/admin/site-finder/weight-profiles";
|
||
|
||
// Никакого X-Admin-Token: сервер перестал его читать в #437, а последний
|
||
// `verify_admin_token` удалён в #2775. Заголовок отправлялся ещё год после этого и
|
||
// ничего не решал — проверено живым запросом на проде (#2782): один и тот же 200
|
||
// с корректным токеном, с мусорным и без заголовка вовсе.
|
||
//
|
||
// Реальная защита `/api/v1/admin/*` — два живых слоя, оба прод-проверены:
|
||
// 1) Caddy basic_auth на gendsgn.ru → без валидных кред 401 ещё на периметре
|
||
// (подставленный клиентом X-Authenticated-User туда же не проходит);
|
||
// 2) app/main.py rbac_guard → `role != "admin"` даёт 403 "admin only".
|
||
|
||
// ── Hooks ─────────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Профили пользователя + системные пресеты (#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)}&include_system=true`,
|
||
),
|
||
enabled: !!userId,
|
||
});
|
||
}
|
||
|
||
/** Create a new weight profile. Invalidates the list query on success. */
|
||
export function useCreateProfile() {
|
||
const qc = useQueryClient();
|
||
return useMutation<WeightProfile, Error, WeightProfileCreate>({
|
||
mutationFn: (payload) =>
|
||
apiFetch<WeightProfile>(BASE_PATH, {
|
||
method: "POST",
|
||
body: JSON.stringify(payload),
|
||
}),
|
||
onSuccess: (_, variables) => {
|
||
void qc.invalidateQueries({
|
||
queryKey: ["weight-profiles", variables.user_id],
|
||
});
|
||
},
|
||
});
|
||
}
|
||
|
||
// useUpdateProfile / useDeleteProfile здесь больше нет (#2790 п.3). Их не звали
|
||
// ниоткуда: в UI есть список и создание, кнопок «переименовать» / «удалить» нет.
|
||
// Спрос за 3 месяца по проду: 1 профиль на всю базу (`admin`, создан 15.05.2026,
|
||
// updated_at = created_at) + 3 системных пресета — ни одного изменения и ни
|
||
// одной попытки удаления. PUT/DELETE-эндпоинты живы и покрыты тестами бэкенда;
|
||
// понадобится UI — хуки вернутся из истории (мертвее они там не станут).
|