diff --git a/backend/app/api/v1/admin_weight_profiles.py b/backend/app/api/v1/admin_weight_profiles.py
index 96b6d9b3..fc187596 100644
--- a/backend/app/api/v1/admin_weight_profiles.py
+++ b/backend/app/api/v1/admin_weight_profiles.py
@@ -26,6 +26,7 @@ from app.services.site_finder.weight_profiles import (
delete_profile,
get_profile,
list_profiles,
+ list_profiles_with_system,
update_profile,
)
@@ -37,8 +38,18 @@ def list_user_profiles(
user_id: Annotated[str, Query(min_length=1, description="user_id для фильтра профилей")],
db: Annotated[Session, Depends(get_db)],
_: AdminTokenAuth,
+ include_system: Annotated[
+ bool,
+ Query(description="Включить системные preset-профили (Эконом/Комфорт/Бизнес)"),
+ ] = False,
) -> Any:
- """Список всех weight profiles для заданного user_id. Default-профиль первым."""
+ """Список всех weight profiles для заданного user_id. Default-профиль первым.
+
+ При include_system=true добавляются системные presets (user_id='__system__'):
+ Эконом, Комфорт, Бизнес — готовые шаблоны весов POI.
+ """
+ if include_system:
+ return list_profiles_with_system(db, user_id)
return list_profiles(db, user_id)
diff --git a/backend/app/services/site_finder/weight_profiles.py b/backend/app/services/site_finder/weight_profiles.py
index 12259896..08a253d1 100644
--- a/backend/app/services/site_finder/weight_profiles.py
+++ b/backend/app/services/site_finder/weight_profiles.py
@@ -26,6 +26,10 @@ from sqlalchemy import text
logger = logging.getLogger(__name__)
+# Sentinel user_id для системных preset-профилей (не привязаны к реальному пользователю).
+# Seed: data/sql/100_user_weight_profiles_default_seed.sql
+SYSTEM_USER_ID: str = "__system__"
+
# Allowed POI categories — single source of truth; imported by api/v1/parcels.py
ALLOWED_CATEGORIES: set[str] = {
"school",
@@ -74,6 +78,17 @@ _SELECT_BY_USER = f"""
ORDER BY is_default DESC, id ASC
"""
+_SELECT_BY_USER_WITH_SYSTEM = f"""
+ SELECT {_SELECT_COLS}
+ FROM user_weight_profiles
+ WHERE user_id = :user_id
+ OR user_id = :system_user_id
+ ORDER BY
+ CASE WHEN user_id = :system_user_id THEN 1 ELSE 0 END ASC,
+ is_default DESC,
+ id ASC
+"""
+
_SELECT_BY_ID = f"""
SELECT {_SELECT_COLS}
FROM user_weight_profiles
@@ -191,6 +206,24 @@ def list_profiles(db: Any, user_id: str) -> list[WeightProfile]:
return [_row_to_profile(r) for r in rows]
+def list_profiles_with_system(db: Any, user_id: str) -> list[WeightProfile]:
+ """Вернуть профили пользователя + системные preset-профили.
+
+ Пользовательские профили идут первыми (default сверху), затем системные
+ presets (Эконом, Комфорт, Бизнес). Предназначен для endpoint с
+ include_system=true — UI dropdown видит и пользовательские, и preset.
+ """
+ rows = (
+ db.execute(
+ text(_SELECT_BY_USER_WITH_SYSTEM),
+ {"user_id": user_id, "system_user_id": SYSTEM_USER_ID},
+ )
+ .mappings()
+ .all()
+ )
+ return [_row_to_profile(r) for r in rows]
+
+
def get_profile(db: Any, user_id: str, profile_id: int) -> WeightProfile | None:
"""Вернуть профиль по id (scoped к пользователю)."""
row = (
diff --git a/backend/tests/test_admin_weight_profiles.py b/backend/tests/test_admin_weight_profiles.py
index 01caa3ed..c3bad23a 100644
--- a/backend/tests/test_admin_weight_profiles.py
+++ b/backend/tests/test_admin_weight_profiles.py
@@ -314,6 +314,93 @@ def test_delete_not_found(client_with_token: TestClient, monkeypatch: pytest.Mon
_clear_overrides()
+# ── include_system preset seed (Issue #114 / PR #229) ─────────────────────────
+
+
+def test_list_include_system_calls_with_system(
+ client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """GET ?include_system=true вызывает list_profiles_with_system, возвращает presets."""
+ system_profile = _make_profile(
+ 100, user_id="__system__", profile_name="Комфорт", weights={"park": 1.8, "school": 1.5}
+ )
+ user_profile = _make_profile(1, user_id="user-1", profile_name="Мой профиль")
+ mock = MagicMock()
+ _override_db(mock)
+ try:
+ monkeypatch.setattr(
+ "app.api.v1.admin_weight_profiles.list_profiles_with_system",
+ lambda db, user_id: [user_profile, system_profile],
+ )
+ r = client_with_token.get(
+ "/api/v1/admin/site-finder/weight-profiles",
+ params={"user_id": "user-1", "include_system": "true"},
+ headers=_HEADERS,
+ )
+ assert r.status_code == 200
+ body = r.json()
+ assert len(body) == 2
+ profile_names = {p["profile_name"] for p in body}
+ assert "Комфорт" in profile_names
+ assert "Мой профиль" in profile_names
+ finally:
+ _clear_overrides()
+
+
+def test_list_without_include_system_does_not_call_with_system(
+ client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """GET без include_system → list_profiles (только пользовательские профили)."""
+ user_profile = _make_profile(1, user_id="user-1")
+ called_with_system = []
+ mock = MagicMock()
+ _override_db(mock)
+ try:
+ monkeypatch.setattr(
+ "app.api.v1.admin_weight_profiles.list_profiles",
+ lambda db, user_id: [user_profile],
+ )
+ monkeypatch.setattr(
+ "app.api.v1.admin_weight_profiles.list_profiles_with_system",
+ lambda db, user_id: called_with_system.append(True) or [],
+ )
+ r = client_with_token.get(
+ "/api/v1/admin/site-finder/weight-profiles",
+ params={"user_id": "user-1"},
+ headers=_HEADERS,
+ )
+ assert r.status_code == 200
+ assert len(r.json()) == 1
+ # list_profiles_with_system НЕ должен вызываться без include_system=true
+ assert called_with_system == [], "вызван list_profiles_with_system без флага"
+ finally:
+ _clear_overrides()
+
+
+def test_list_profiles_with_system_service(monkeypatch: pytest.MonkeyPatch) -> None:
+ """list_profiles_with_system передаёт system_user_id='__system__' в запрос."""
+ from app.services.site_finder.weight_profiles import SYSTEM_USER_ID, list_profiles_with_system
+
+ captured_params: list[dict] = []
+
+ class FakeResult:
+ def mappings(self) -> FakeResult:
+ return self
+
+ def all(self) -> list:
+ return []
+
+ class FakeDb:
+ def execute(self, query: object, params: dict) -> FakeResult:
+ captured_params.append(params)
+ return FakeResult()
+
+ list_profiles_with_system(FakeDb(), user_id="user-test")
+ assert len(captured_params) == 1
+ assert captured_params[0]["system_user_id"] == SYSTEM_USER_ID
+ assert captured_params[0]["user_id"] == "user-test"
+
+
# ── Auth ───────────────────────────────────────────────────────────────────────
diff --git a/data/sql/100_user_weight_profiles_default_seed.sql b/data/sql/100_user_weight_profiles_default_seed.sql
new file mode 100644
index 00000000..546061cf
--- /dev/null
+++ b/data/sql/100_user_weight_profiles_default_seed.sql
@@ -0,0 +1,90 @@
+-- 100_user_weight_profiles_default_seed.sql
+-- Системные preset-профили весов POI для Site Finder.
+-- Per #114 (Макс feedback): садики и Мегамарт должны иметь разные веса.
+--
+-- Apply order: после 90_user_weight_profiles.sql (таблица уже существует)
+-- Dependencies: user_weight_profiles table
+--
+-- Sentinel owner: '__system__' — не является реальным user_id,
+-- используется только для системных presets.
+-- UI: dropdown показывает system presets всем пользователям через
+-- GET /api/v1/admin/site-finder/weight-profiles?user_id=__system__
+-- или include_system=true (добавлен в #114 seed PR).
+--
+-- Idempotent: ON CONFLICT (user_id, profile_name) DO UPDATE — обновляет
+-- веса если preset уже существует (safe re-apply на prod).
+
+BEGIN;
+
+INSERT INTO user_weight_profiles
+ (user_id, profile_name, weights, is_default, description)
+VALUES
+ (
+ '__system__',
+ 'Эконом',
+ '{
+ "school": 1.2,
+ "kindergarten": 1.0,
+ "pharmacy": 0.8,
+ "hospital": 0.5,
+ "shop_mall": 0.8,
+ "shop_supermarket": 1.5,
+ "shop_small": 1.0,
+ "park": 1.2,
+ "bus_stop": 1.0,
+ "metro_stop": 0.8,
+ "tram_stop": 0.5
+ }'::jsonb,
+ FALSE,
+ 'Эконом-класс: доступность магазинов и транспорта важнее премиальной инфраструктуры'
+ ),
+ (
+ '__system__',
+ 'Комфорт',
+ '{
+ "school": 1.5,
+ "kindergarten": 1.8,
+ "pharmacy": 0.8,
+ "hospital": 0.6,
+ "shop_mall": 1.0,
+ "shop_supermarket": 1.2,
+ "shop_small": 0.5,
+ "park": 1.8,
+ "bus_stop": 0.5,
+ "metro_stop": 1.2,
+ "tram_stop": -0.3
+ }'::jsonb,
+ FALSE,
+ 'Комфорт-класс: парки, детсады и школы в приоритете — семейная аудитория'
+ ),
+ (
+ '__system__',
+ 'Бизнес',
+ '{
+ "school": 1.0,
+ "kindergarten": 0.8,
+ "pharmacy": 0.6,
+ "hospital": 0.5,
+ "shop_mall": 2.0,
+ "shop_supermarket": 0.8,
+ "shop_small": 0.3,
+ "park": 2.0,
+ "bus_stop": 0.2,
+ "metro_stop": 2.5,
+ "tram_stop": -0.5
+ }'::jsonb,
+ FALSE,
+ 'Бизнес-класс: метро, ТЦ и парки в приоритете — платёжеспособная аудитория'
+ )
+ON CONFLICT (user_id, profile_name)
+DO UPDATE SET
+ weights = EXCLUDED.weights,
+ description = EXCLUDED.description,
+ updated_at = NOW();
+
+COMMENT ON TABLE user_weight_profiles IS
+ 'POI weight profiles для site_finder. Per #114 (Макс feedback). '
+ 'weights = {"school": 1.5, "kindergarten": 1.5, "shop_mall": 1.2, ...}. '
+ 'Системные presets: user_id = ''__system__'' (3 preset: Эконом/Комфорт/Бизнес).';
+
+COMMIT;
diff --git a/frontend/src/app/site-finder/page.tsx b/frontend/src/app/site-finder/page.tsx
index baa15870..460a9c00 100644
--- a/frontend/src/app/site-finder/page.tsx
+++ b/frontend/src/app/site-finder/page.tsx
@@ -16,6 +16,7 @@ import { MarketTab } from "@/components/site-finder/MarketTab";
import { WeightProfilePanel } from "@/components/site-finder/WeightProfilePanel";
import { useSiteAnalysis } from "@/hooks/useSiteAnalysis";
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
+import { useConnectionPoints } from "@/hooks/useConnectionPoints";
import {
POI_DEFAULT_WEIGHTS,
type PoiCategoryKey,
@@ -98,6 +99,9 @@ function SiteFinderContent() {
undefined,
);
+ // Fetch connection points whenever a parcel is loaded
+ const { data: connectionPoints } = useConnectionPoints(data?.cad_num);
+
// Weight profile state — lifted here so it survives tab switches.
// userId + adminToken allow the panel to load/save named profiles.
const [currentWeights, setCurrentWeights] = useState<
@@ -537,7 +541,11 @@ function SiteFinderContent() {
gap: 12,
}}
>
-