gendesign/frontend/src/lib/api/weightProfiles.ts
bot-backend 12bc9f346f
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
fix(site-finder): §4.1 «Применить» действительно применяет веса POI (#2790)
Состояние ползунков было 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
2026-08-10 13:29:54 +05:00

151 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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