From a6e4ff0407918106acdc1e24a918d3ed6db9abf1 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 16 May 2026 22:49:56 +0300 Subject: [PATCH 1/2] feat(#114): seed 3 default weight presets + include_system API param MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add SQL migration 100_user_weight_profiles_default_seed.sql with system presets Эконом/Комфорт/Бизнес (user_id='__system__'). Migration is idempotent via ON CONFLICT DO UPDATE. Backend: - weight_profiles.py: add SYSTEM_USER_ID constant + list_profiles_with_system() - admin_weight_profiles.py: add include_system query param to GET list endpoint Tests: 3 new tests covering include_system flag and service sentinel behaviour. --- backend/app/api/v1/admin_weight_profiles.py | 13 ++- .../services/site_finder/weight_profiles.py | 33 +++++++ backend/tests/test_admin_weight_profiles.py | 87 ++++++++++++++++++ .../100_user_weight_profiles_default_seed.sql | 90 +++++++++++++++++++ 4 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 data/sql/100_user_weight_profiles_default_seed.sql 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; -- 2.45.3 From a7671513c9e70929a1928cc5f41deb979a5153bc Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 16 May 2026 22:51:12 +0300 Subject: [PATCH 2/2] =?UTF-8?q?feat(#115):=20Leaflet=20layer=20toggle=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20connection=20points=20(=D0=9C=D0=B0=D0=BA?= =?UTF-8?q?=D1=81=20KILLER)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ConnectionPointsLayer.tsx — per-category CircleMarker слой внутри MapContainer (electricity/gas/water/heat/sewage/telecom/other, классификация по keywords) - CpLayerControlPanel.tsx — toggle panel под картой: checkbox per-category + count, toggle-all, summary badges (ближайший, охранная зона) - SiteMap.tsx — принимает connectionPoints?: ConnectionPointsResponse, управляет visibleCategories state, рендерит оба новых компонента - page.tsx — useConnectionPoints(data?.cad_num) -> передаёт в SiteMap - Empty-state: "0 точек подключения" при dump_available=false или пустом ответе --- frontend/src/app/site-finder/page.tsx | 10 +- .../site-finder/ConnectionPointsLayer.tsx | 224 ++++++++++++++++++ .../site-finder/CpLayerControlPanel.tsx | 224 ++++++++++++++++++ .../src/components/site-finder/SiteMap.tsx | 65 ++++- 4 files changed, 520 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/site-finder/ConnectionPointsLayer.tsx create mode 100644 frontend/src/components/site-finder/CpLayerControlPanel.tsx 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, }} > - + diff --git a/frontend/src/components/site-finder/ConnectionPointsLayer.tsx b/frontend/src/components/site-finder/ConnectionPointsLayer.tsx new file mode 100644 index 00000000..1e25173a --- /dev/null +++ b/frontend/src/components/site-finder/ConnectionPointsLayer.tsx @@ -0,0 +1,224 @@ +"use client"; + +import { CircleMarker, Popup, LayerGroup } from "react-leaflet"; + +import type { EngineeringStructure } from "@/types/nspd"; + +// --------------------------------------------------------------------------- +// Category classification +// --------------------------------------------------------------------------- + +export type CpCategory = + | "electricity" + | "gas" + | "water" + | "heat" + | "sewage" + | "telecom" + | "other"; + +export interface CpCategoryStyle { + color: string; + label: string; + radius: number; +} + +export const CP_CATEGORY_STYLES: Record = { + electricity: { color: "#f59e0b", label: "Электричество", radius: 9 }, + gas: { color: "#3b82f6", label: "Газ", radius: 9 }, + water: { color: "#06b6d4", label: "Вода", radius: 8 }, + heat: { color: "#ef4444", label: "Теплоснабжение", radius: 8 }, + sewage: { color: "#8b5cf6", label: "Канализация", radius: 8 }, + telecom: { color: "#10b981", label: "Связь", radius: 7 }, + other: { color: "#6b7280", label: "Другое", radius: 7 }, +}; + +export const CP_ALL_CATEGORIES = Object.keys( + CP_CATEGORY_STYLES, +) as CpCategory[]; + +// Keywords to match against `type` and `name` fields (case-insensitive) +const CATEGORY_KEYWORDS: Array<{ cat: CpCategory; keywords: string[] }> = [ + { + cat: "electricity", + keywords: [ + "трансформатор", + "тп-", + "ктп", + "подстанция", + "электр", + "tp-", + "лэп", + "36328", + ], + }, + { + cat: "gas", + keywords: ["газ", "гтс", "газоп", "газорегул", "газопровод"], + }, + { + cat: "water", + keywords: ["водо", "водопр", "насосн", "колодец", "скважин"], + }, + { + cat: "heat", + keywords: ["тепло", "теплос", "котельн", "тэц"], + }, + { + cat: "sewage", + keywords: ["канал", "сток", "ливнев", "кнс", "канализ"], + }, + { + cat: "telecom", + keywords: ["связ", "телеком", "интернет", "оптик", "вышка"], + }, +]; + +export function classifyStructure(s: EngineeringStructure): CpCategory { + const haystack = + `${s.name ?? ""} ${s.type ?? ""} ${s.source ?? ""}`.toLowerCase(); + for (const { cat, keywords } of CATEGORY_KEYWORDS) { + if (keywords.some((kw) => haystack.includes(kw))) return cat; + } + return "other"; +} + +// --------------------------------------------------------------------------- +// Group helper (exported so SiteMap can build counts for the control panel) +// --------------------------------------------------------------------------- + +export function groupStructuresByCategory( + structures: EngineeringStructure[], +): Map { + const grouped = new Map(); + for (const cat of CP_ALL_CATEGORIES) { + grouped.set(cat, []); + } + for (const s of structures) { + const cat = classifyStructure(s); + grouped.get(cat)!.push(s); + } + return grouped; +} + +// --------------------------------------------------------------------------- +// Geometry helpers +// --------------------------------------------------------------------------- + +function extractLatLon( + geojson: Record, +): [number, number] | null { + if (geojson.type === "Point") { + const coords = geojson.coordinates as number[] | undefined; + if (coords && coords.length >= 2) { + // GeoJSON: [lon, lat] + return [coords[1], coords[0]]; + } + } + return null; +} + +// --------------------------------------------------------------------------- +// Map layer — renders CircleMarkers inside the Leaflet MapContainer +// --------------------------------------------------------------------------- + +interface LayerProps { + visibleCategories: Set; + grouped: Map; +} + +export function ConnectionPointsLayer({ + visibleCategories, + grouped, +}: LayerProps) { + return ( + <> + {CP_ALL_CATEGORIES.map((cat) => { + if (!visibleCategories.has(cat)) return null; + const structs = grouped.get(cat) ?? []; + const style = CP_CATEGORY_STYLES[cat]; + + return ( + + {structs.map((s, idx) => { + const latLon = extractLatLon(s.geometry_geojson); + if (!latLon) return null; + + return ( + + +
+
+ + {style.label} +
+
+ {s.name ?? s.type ?? "Объект"} +
+ {s.type && s.name && ( +
+ {s.type} +
+ )} + {s.readable_address && ( +
+ {s.readable_address} +
+ )} +
+ До границы:{" "} + + {Math.round(s.distance_to_boundary_m)} м + +
+
+ Источник: {s.source} +
+
+
+
+ ); + })} +
+ ); + })} + + ); +} diff --git a/frontend/src/components/site-finder/CpLayerControlPanel.tsx b/frontend/src/components/site-finder/CpLayerControlPanel.tsx new file mode 100644 index 00000000..df16d14b --- /dev/null +++ b/frontend/src/components/site-finder/CpLayerControlPanel.tsx @@ -0,0 +1,224 @@ +"use client"; + +import { useState } from "react"; + +import type { + ConnectionPointsResponse, + EngineeringStructure, +} from "@/types/nspd"; +import { + CP_ALL_CATEGORIES, + CP_CATEGORY_STYLES, + type CpCategory, +} from "@/components/site-finder/ConnectionPointsLayer"; + +interface Props { + data: ConnectionPointsResponse; + grouped: Map; + visibleCategories: Set; + onToggleCategory: (cat: CpCategory) => void; + onToggleAll: () => void; +} + +export function CpLayerControlPanel({ + data, + grouped, + visibleCategories, + onToggleCategory, + onToggleAll, +}: Props) { + const [collapsed, setCollapsed] = useState(false); + + const totalCount = data.engineering_structures.length; + const allVisible = visibleCategories.size === CP_ALL_CATEGORIES.length; + + return ( +
+ {/* Header */} +
setCollapsed((v) => !v)} + > + + Точки подключения + + + {totalCount} шт {collapsed ? "▲" : "▼"} + +
+ + {!collapsed && ( +
+ {/* No dump */} + {!data.dump_available && ( +
+ Дамп квартала не загружен — 0 точек подключения +
+ )} + + {/* Empty state */} + {data.dump_available && totalCount === 0 && ( +
+ 0 точек подключения в этом квартале +
+ )} + + {/* Toggle-all */} + {totalCount > 0 && ( + + )} + + {/* Per-category */} +
+ {CP_ALL_CATEGORIES.map((cat) => { + const structs = grouped.get(cat) ?? []; + const style = CP_CATEGORY_STYLES[cat]; + if (structs.length === 0) return null; + const active = visibleCategories.has(cat); + return ( + + ); + })} +
+ + {/* Summary */} + {data.dump_available && totalCount > 0 && ( +
+ {data.summary.nearest_structure_distance_m !== null && ( + + Ближайший:{" "} + {Math.round(data.summary.nearest_structure_distance_m)} м + + )} + {data.summary.in_protection_zone && ( + + В охранной зоне + + )} + {data.summary.protection_zones_intersecting > 0 && + !data.summary.in_protection_zone && ( + + Охранных зон: {data.summary.protection_zones_intersecting} + + )} +
+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/site-finder/SiteMap.tsx b/frontend/src/components/site-finder/SiteMap.tsx index 9c007246..5b81b8d1 100644 --- a/frontend/src/components/site-finder/SiteMap.tsx +++ b/frontend/src/components/site-finder/SiteMap.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { MapContainer, TileLayer, @@ -12,6 +12,17 @@ import type { Feature, FeatureCollection, Geometry, Position } from "geojson"; import "leaflet/dist/leaflet.css"; import type { ParcelAnalysis } from "@/types/site-finder"; +import type { + ConnectionPointsResponse, + EngineeringStructure, +} from "@/types/nspd"; +import { + ConnectionPointsLayer, + CP_ALL_CATEGORIES, + groupStructuresByCategory, + type CpCategory, +} from "@/components/site-finder/ConnectionPointsLayer"; +import { CpLayerControlPanel } from "@/components/site-finder/CpLayerControlPanel"; // --------------------------------------------------------------------------- // POI legend config (for the legend row below the map) @@ -83,9 +94,10 @@ function geomCenter(geom: Geometry): [number, number] { interface Props { data: ParcelAnalysis; isochrones?: FeatureCollection; + connectionPoints?: ConnectionPointsResponse; } -export function SiteMap({ data, isochrones }: Props) { +export function SiteMap({ data, isochrones, connectionPoints }: Props) { // Fix Leaflet default icon paths broken by webpack bundler useEffect(() => { void import("leaflet").then((L) => { @@ -101,6 +113,31 @@ export function SiteMap({ data, isochrones }: Props) { }); }, []); + // Connection-points layer toggle state (all categories visible by default) + const [visibleCategories, setVisibleCategories] = useState>( + new Set(CP_ALL_CATEGORIES), + ); + + function toggleCategory(cat: CpCategory) { + setVisibleCategories((prev) => { + const next = new Set(prev); + if (next.has(cat)) { + next.delete(cat); + } else { + next.add(cat); + } + return next; + }); + } + + function toggleAll() { + setVisibleCategories((prev) => + prev.size === CP_ALL_CATEGORIES.length + ? new Set() + : new Set(CP_ALL_CATEGORIES), + ); + } + const center: [number, number] = data.geom_geojson ? geomCenter(data.geom_geojson) : [56.838, 60.6]; @@ -108,6 +145,11 @@ export function SiteMap({ data, isochrones }: Props) { // Build legend from categories present in score_breakdown const presentCategories = Object.keys(data.score_breakdown); + // Pre-group structures for both the map layer and the control panel + const cpGrouped = connectionPoints + ? groupStructuresByCategory(connectionPoints.engineering_structures) + : new Map(); + return (
{/* Map */} @@ -222,6 +264,14 @@ export function SiteMap({ data, isochrones }: Props) { )); })} + + {/* Connection points layer — rendered on top of POI markers */} + {connectionPoints && ( + + )}
@@ -278,6 +328,17 @@ export function SiteMap({ data, isochrones }: Props) { Геометрия участка не найдена — на карте нет полигона

)} + + {/* Connection points layer control panel — below the map */} + {connectionPoints && ( + + )} ); } -- 2.45.3