feat(site-finder): inline POI weights pass-through в /analyze (#201 Phase 1)
Critical UX fix для #114 — user-drag слайдеры в WeightProfilePanel теперь применяются immediately к scoring, без обязательного profile save. Backend (parcels.py + schemas/parcel.py + tests): - POST /analyze принимает optional AnalyzeRequest { weights: dict[str,float] | None } - Priority: inline → profile_id → user_default → system defaults - Validate against ALLOWED_CATEGORIES + [MIN_WEIGHT, MAX_WEIGHT] → 422 на violation - Partial override semantics - 5 mock tests Frontend (useSiteAnalysis.ts + page.tsx): - weights param в analyze mutation - handleAnalyze всегда передаёт currentWeights когда activeProfileId=null - handleWeightsChange re-trigger analyze immediately если parcel loaded Phase 2 (debounce) + Phase 3 (Edit/Delete UI) — follow-up.
This commit is contained in:
parent
434341e98f
commit
038e39e2ec
5 changed files with 419 additions and 15 deletions
|
|
@ -6,7 +6,7 @@ import time
|
||||||
from typing import Annotated, Any
|
from typing import Annotated, Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Response
|
||||||
from shapely import wkt as _shp_wkt
|
from shapely import wkt as _shp_wkt
|
||||||
from shapely.geometry import Polygon
|
from shapely.geometry import Polygon
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
@ -15,6 +15,7 @@ from sqlalchemy.orm import Session
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.schemas.parcel import (
|
from app.schemas.parcel import (
|
||||||
|
AnalyzeRequest,
|
||||||
BestLayoutsRequest,
|
BestLayoutsRequest,
|
||||||
BestLayoutsResponse,
|
BestLayoutsResponse,
|
||||||
CompetitorsRequest,
|
CompetitorsRequest,
|
||||||
|
|
@ -41,6 +42,18 @@ from app.services.site_finder.quarter_dump_lookup import (
|
||||||
make_empty_result,
|
make_empty_result,
|
||||||
)
|
)
|
||||||
from app.services.site_finder.velocity import compute_velocity
|
from app.services.site_finder.velocity import compute_velocity
|
||||||
|
from app.services.site_finder.weight_profiles import (
|
||||||
|
ALLOWED_CATEGORIES as _ALLOWED_CATEGORIES,
|
||||||
|
)
|
||||||
|
from app.services.site_finder.weight_profiles import (
|
||||||
|
MAX_WEIGHT as _MAX_WEIGHT,
|
||||||
|
)
|
||||||
|
from app.services.site_finder.weight_profiles import (
|
||||||
|
MIN_WEIGHT as _MIN_WEIGHT,
|
||||||
|
)
|
||||||
|
from app.services.site_finder.weight_profiles import (
|
||||||
|
resolve_weights as _resolve_weights,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -1048,6 +1061,10 @@ def analyze_parcel(
|
||||||
str | None,
|
str | None,
|
||||||
Query(description="user_id для fallback на default-профиль пользователя"),
|
Query(description="user_id для fallback на default-профиль пользователя"),
|
||||||
] = None,
|
] = None,
|
||||||
|
body: Annotated[
|
||||||
|
AnalyzeRequest | None,
|
||||||
|
Body(description="Опциональное тело запроса: inline POI-веса (#201)"),
|
||||||
|
] = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Анализ участка: близость к социалке + district context + конкуренты.
|
"""Анализ участка: близость к социалке + district context + конкуренты.
|
||||||
|
|
||||||
|
|
@ -1223,15 +1240,41 @@ def analyze_parcel(
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
# 3b) Resolve effective POI weights (profile → user default → system)
|
# 3b) Resolve effective POI weights (inline → profile → user default → system)
|
||||||
from app.services.site_finder.weight_profiles import resolve_weights as _resolve_weights
|
_inline_weights: dict[str, float] | None = body.weights if body is not None else None
|
||||||
|
|
||||||
_effective_weights = _resolve_weights(db, user_id=profile_user_id, profile_id=profile_id)
|
if _inline_weights is not None:
|
||||||
_weights_source = (
|
# Validate inline weights: keys и диапазон значений (#201)
|
||||||
"profile"
|
bad_keys = set(_inline_weights.keys()) - _ALLOWED_CATEGORIES
|
||||||
if profile_id is not None
|
if bad_keys:
|
||||||
else ("user_default" if profile_user_id is not None else "system")
|
raise HTTPException(
|
||||||
)
|
status_code=422,
|
||||||
|
detail=(
|
||||||
|
f"Неизвестные POI-категории: {sorted(bad_keys)}. "
|
||||||
|
f"Допустимые: {sorted(_ALLOWED_CATEGORIES)}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
out_of_range = {
|
||||||
|
k: v for k, v in _inline_weights.items() if v < _MIN_WEIGHT or v > _MAX_WEIGHT
|
||||||
|
}
|
||||||
|
if out_of_range:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=(
|
||||||
|
f"Веса за пределами допустимого диапазона "
|
||||||
|
f"[{_MIN_WEIGHT}, {_MAX_WEIGHT}]: {out_of_range}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# Inline weights applied — merge поверх системных defaults (partial override)
|
||||||
|
_effective_weights = {**_POI_WEIGHTS, **_inline_weights}
|
||||||
|
_weights_source = "inline"
|
||||||
|
else:
|
||||||
|
_effective_weights = _resolve_weights(db, user_id=profile_user_id, profile_id=profile_id)
|
||||||
|
_weights_source = (
|
||||||
|
"profile"
|
||||||
|
if profile_id is not None
|
||||||
|
else ("user_default" if profile_user_id is not None else "system")
|
||||||
|
)
|
||||||
|
|
||||||
# 4) Scoring: weighted sum с distance decay
|
# 4) Scoring: weighted sum с distance decay
|
||||||
score = 0.0
|
score = 0.0
|
||||||
|
|
@ -1926,12 +1969,13 @@ def analyze_parcel(
|
||||||
nspd_engineering_nearby=nspd_dump_data["nspd_engineering_nearby"],
|
nspd_engineering_nearby=nspd_dump_data["nspd_engineering_nearby"],
|
||||||
nspd_dump=nspd_dump_data["nspd_dump"],
|
nspd_dump=nspd_dump_data["nspd_dump"],
|
||||||
),
|
),
|
||||||
# #114: кастомные веса POI — source + applied dict для прозрачности.
|
# #114/#201: кастомные веса POI — source + applied dict для прозрачности.
|
||||||
"weights_profile": {
|
"weights_profile": {
|
||||||
"source": _weights_source,
|
"source": _weights_source,
|
||||||
"profile_id": profile_id,
|
"profile_id": profile_id,
|
||||||
"user_id": profile_user_id,
|
"user_id": profile_user_id,
|
||||||
"weights_applied": _effective_weights,
|
"weights_applied": _effective_weights,
|
||||||
|
"inline_weights": _inline_weights,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -213,3 +213,24 @@ class BestLayoutsResponse(BaseModel):
|
||||||
top_layouts: list[TopLayoutRow]
|
top_layouts: list[TopLayoutRow]
|
||||||
recommendation_for_tz: LayoutTzRecommendation
|
recommendation_for_tz: LayoutTzRecommendation
|
||||||
data_quality: LayoutDataQuality
|
data_quality: LayoutDataQuality
|
||||||
|
|
||||||
|
|
||||||
|
# ── Analyze endpoint inline weights (#201) ────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyzeRequest(BaseModel):
|
||||||
|
"""Опциональное тело запроса POST /analyze.
|
||||||
|
|
||||||
|
Позволяет передать inline POI-веса напрямую в запросе без сохранения
|
||||||
|
профиля. Если задан weights — применяется с наивысшим приоритетом
|
||||||
|
(выше profile_id и user default).
|
||||||
|
"""
|
||||||
|
|
||||||
|
weights: dict[str, float] | None = Field(
|
||||||
|
default=None,
|
||||||
|
description=(
|
||||||
|
"Inline POI weights override (категория → weight). "
|
||||||
|
"Если задан — применяется к scoring, без обязательного profile save. "
|
||||||
|
"Validated против ALLOWED_CATEGORIES + MIN_WEIGHT/MAX_WEIGHT."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
|
||||||
302
backend/tests/api/v1/test_analyze_inline_weights.py
Normal file
302
backend/tests/api/v1/test_analyze_inline_weights.py
Normal file
|
|
@ -0,0 +1,302 @@
|
||||||
|
"""Тесты для inline POI-weights в POST /api/v1/parcels/{cad_num}/analyze (#201).
|
||||||
|
|
||||||
|
Покрывает:
|
||||||
|
1. POST /analyze без body → system defaults (no regression)
|
||||||
|
2. POST /analyze с inline weights → applied (source = "inline")
|
||||||
|
3. POST /analyze с невалидной категорией → 422
|
||||||
|
4. POST /analyze с весом вне диапазона → 422
|
||||||
|
5. POST /analyze с body.weights + profile_id → body.weights wins (priority)
|
||||||
|
|
||||||
|
Стратегия mock: DB патчим через dependency_overrides, тяжёлые service-функции
|
||||||
|
(weather, velocity, dump и т.д.) патчим через unittest.mock.patch — чтобы не
|
||||||
|
дублировать все 18 db.execute call'ов в каждом тесте.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
# ── Константы ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_CAD = "66:41:0204016:10"
|
||||||
|
_WKT = "POLYGON((60.6 56.838, 60.61 56.838, 60.61 56.845, 60.6 56.845, 60.6 56.838))"
|
||||||
|
_GEOJSON = '{"type":"Polygon","coordinates":[[[60.6,56.838],[60.61,56.838]]]}'
|
||||||
|
|
||||||
|
|
||||||
|
# ── Mock factories ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _make_mapping(data: dict[str, Any]) -> MagicMock:
|
||||||
|
"""Создать mock-строку (mapping) с __getitem__ + .get() для dict-like доступа."""
|
||||||
|
m = MagicMock()
|
||||||
|
m.__getitem__ = lambda self, k: data[k]
|
||||||
|
m.get = lambda k, default=None: data.get(k, default)
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
def _make_db_for_analyze(
|
||||||
|
geom_found: bool = True,
|
||||||
|
district_found: bool = True,
|
||||||
|
poi_rows: list[Any] | None = None,
|
||||||
|
) -> MagicMock:
|
||||||
|
"""Сконструировать mock DB Session для analyze_parcel.
|
||||||
|
|
||||||
|
Порядок db.execute calls в analyze_parcel:
|
||||||
|
0. UNION ALL geom + source → .mappings().first()
|
||||||
|
1. WKT query → .mappings().first()
|
||||||
|
2. District → .mappings().first()
|
||||||
|
3. POI rows → .mappings().all()
|
||||||
|
4. Competitor rows → .mappings().all()
|
||||||
|
5. Pipeline rows → .mappings().all()
|
||||||
|
6. Centroid lat/lon → .mappings().first()
|
||||||
|
7. Noise rows → .mappings().all()
|
||||||
|
8. Hydrology → .mappings().all()
|
||||||
|
9. Utilities → .mappings().all()
|
||||||
|
10. Market trend → .mappings().first()
|
||||||
|
11. Zoning (begin_nested) → .mappings().first()
|
||||||
|
12. Success recommendation (begin_nested) → .mappings().all()
|
||||||
|
13. _geotech_risk (industrial count) → .scalar()
|
||||||
|
14. _neighbors_summary (neighbor_rows) → .mappings().all()
|
||||||
|
15. _neighbors_summary (overlap_row) → .mappings().first()
|
||||||
|
|
||||||
|
begin_nested() — возвращаем context manager чтобы поддержать `with` statement.
|
||||||
|
"""
|
||||||
|
db = MagicMock()
|
||||||
|
|
||||||
|
geom_row = (
|
||||||
|
_make_mapping({"geom_geojson": _GEOJSON, "geom_wkb": None, "source": "cad_quarter"})
|
||||||
|
if geom_found
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
wkt_row = _make_mapping({"wkt": _WKT}) if geom_found else None
|
||||||
|
district_row = (
|
||||||
|
_make_mapping(
|
||||||
|
{
|
||||||
|
"district_name": "Октябрьский",
|
||||||
|
"median_price_per_m2": 120000,
|
||||||
|
"dist_to_center": 1500.0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if district_found
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
centroid_row = _make_mapping({"lat": 56.84, "lon": 60.605})
|
||||||
|
|
||||||
|
_poi_rows = poi_rows or []
|
||||||
|
|
||||||
|
# Счётчик вызовов execute — разводим first() / all() / scalar() по очерёдности
|
||||||
|
call_idx = [0]
|
||||||
|
# Ответы в порядке вызовов:
|
||||||
|
responses: list[Any] = [
|
||||||
|
("first", geom_row), # 0: geom UNION ALL
|
||||||
|
("first", wkt_row), # 1: WKT
|
||||||
|
("first", district_row), # 2: district
|
||||||
|
("all", _poi_rows), # 3: POI rows
|
||||||
|
("all", []), # 4: competitor rows
|
||||||
|
("all", []), # 5: pipeline rows
|
||||||
|
("first", centroid_row), # 6: centroid
|
||||||
|
("all", []), # 7: noise rows
|
||||||
|
("all", []), # 8: hydrology rows
|
||||||
|
("all", []), # 9: utilities rows
|
||||||
|
("first", None), # 10: market trend
|
||||||
|
("first", None), # 11: zoning (inside begin_nested)
|
||||||
|
("all", []), # 12: success recommendation (inside begin_nested)
|
||||||
|
("scalar", 0), # 13: geotech_risk industrial count
|
||||||
|
("all", []), # 14: neighbors
|
||||||
|
("first", None), # 15: overlap
|
||||||
|
]
|
||||||
|
|
||||||
|
def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:
|
||||||
|
idx = call_idx[0]
|
||||||
|
call_idx[0] += 1
|
||||||
|
if idx >= len(responses):
|
||||||
|
# Безопасный fallback для непредусмотренных вызовов
|
||||||
|
r = MagicMock()
|
||||||
|
r.mappings.return_value.first.return_value = None
|
||||||
|
r.mappings.return_value.all.return_value = []
|
||||||
|
r.scalar.return_value = 0
|
||||||
|
return r
|
||||||
|
kind, data = responses[idx]
|
||||||
|
r = MagicMock()
|
||||||
|
r.mappings.return_value.first.return_value = data
|
||||||
|
r.mappings.return_value.all.return_value = data if isinstance(data, list) else []
|
||||||
|
r.scalar.return_value = data if kind == "scalar" else 0
|
||||||
|
return r
|
||||||
|
|
||||||
|
db.execute.side_effect = _execute_side_effect
|
||||||
|
|
||||||
|
# begin_nested() → context manager, остальные execute внутри него проходят
|
||||||
|
# через тот же side_effect (because db.execute is the same mock).
|
||||||
|
ctx = MagicMock()
|
||||||
|
ctx.__enter__ = MagicMock(return_value=ctx)
|
||||||
|
ctx.__exit__ = MagicMock(return_value=False)
|
||||||
|
db.begin_nested.return_value = ctx
|
||||||
|
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
def _override_db(db: MagicMock):
|
||||||
|
def _get_db_override():
|
||||||
|
yield db
|
||||||
|
|
||||||
|
return _get_db_override
|
||||||
|
|
||||||
|
|
||||||
|
# Патчим тяжёлые внешние вызовы (weather / velocity / nspd-dump),
|
||||||
|
# чтобы тесты не зависели от сети и не требовали полного mock DB.
|
||||||
|
_PATCHES = [
|
||||||
|
patch("app.api.v1.parcels._fetch_air_quality_sync", return_value=None),
|
||||||
|
patch("app.api.v1.parcels._fetch_weather_sync", return_value=None),
|
||||||
|
patch("app.api.v1.parcels._fetch_seasonal_weather_sync", return_value=None),
|
||||||
|
patch(
|
||||||
|
"app.api.v1.parcels.get_quarter_dump_data",
|
||||||
|
return_value={
|
||||||
|
"nspd_zoning": None,
|
||||||
|
"nspd_zouit_overlaps": [],
|
||||||
|
"nspd_engineering_nearby": [],
|
||||||
|
"nspd_dump": {"available": False, "stale": False, "harvest_triggered": False},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
patch("app.api.v1.parcels.compute_velocity", return_value=None),
|
||||||
|
patch("app.api.v1.parcels.compute_gate_verdict", return_value={"verdict": "unknown"}),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _start_patches() -> list[Any]:
|
||||||
|
started = [p.start() for p in _PATCHES]
|
||||||
|
return started
|
||||||
|
|
||||||
|
|
||||||
|
def _stop_patches() -> None:
|
||||||
|
for p in _PATCHES:
|
||||||
|
p.stop()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Тесты ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_analyze_no_body_uses_system_defaults() -> None:
|
||||||
|
"""POST /analyze без body → source = 'system', нет регрессии."""
|
||||||
|
from app.core.db import get_db
|
||||||
|
|
||||||
|
db = _make_db_for_analyze()
|
||||||
|
app.dependency_overrides[get_db] = _override_db(db)
|
||||||
|
_start_patches()
|
||||||
|
try:
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert "weights_profile" in body
|
||||||
|
assert body["weights_profile"]["source"] == "system"
|
||||||
|
assert body["weights_profile"]["inline_weights"] is None
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
_stop_patches()
|
||||||
|
|
||||||
|
|
||||||
|
def test_analyze_inline_weights_applied() -> None:
|
||||||
|
"""POST /analyze с body.weights → source = 'inline', веса применены."""
|
||||||
|
from app.core.db import get_db
|
||||||
|
|
||||||
|
db = _make_db_for_analyze()
|
||||||
|
app.dependency_overrides[get_db] = _override_db(db)
|
||||||
|
_start_patches()
|
||||||
|
try:
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/v1/parcels/{_CAD}/analyze",
|
||||||
|
json={"weights": {"kindergarten": 2.5}},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
wp = body["weights_profile"]
|
||||||
|
assert wp["source"] == "inline"
|
||||||
|
assert wp["inline_weights"] == {"kindergarten": 2.5}
|
||||||
|
# applied weights содержат inline override поверх defaults
|
||||||
|
assert wp["weights_applied"]["kindergarten"] == pytest.approx(2.5)
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
_stop_patches()
|
||||||
|
|
||||||
|
|
||||||
|
def test_analyze_invalid_category_returns_422() -> None:
|
||||||
|
"""POST /analyze с невалидной POI-категорией → 422."""
|
||||||
|
from app.core.db import get_db
|
||||||
|
|
||||||
|
db = _make_db_for_analyze()
|
||||||
|
app.dependency_overrides[get_db] = _override_db(db)
|
||||||
|
_start_patches()
|
||||||
|
try:
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/v1/parcels/{_CAD}/analyze",
|
||||||
|
json={"weights": {"nonexistent_category": 1.0}},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422, resp.text
|
||||||
|
detail = resp.json()["detail"]
|
||||||
|
assert "nonexistent_category" in detail
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
_stop_patches()
|
||||||
|
|
||||||
|
|
||||||
|
def test_analyze_weight_out_of_range_returns_422() -> None:
|
||||||
|
"""POST /analyze с весом вне [-2, 3] → 422."""
|
||||||
|
from app.core.db import get_db
|
||||||
|
|
||||||
|
db = _make_db_for_analyze()
|
||||||
|
app.dependency_overrides[get_db] = _override_db(db)
|
||||||
|
_start_patches()
|
||||||
|
try:
|
||||||
|
client = TestClient(app)
|
||||||
|
# Слишком большой вес
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/v1/parcels/{_CAD}/analyze",
|
||||||
|
json={"weights": {"school": 99.9}},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422, resp.text
|
||||||
|
detail = resp.json()["detail"]
|
||||||
|
assert "school" in detail
|
||||||
|
|
||||||
|
# Слишком маленький вес
|
||||||
|
resp2 = client.post(
|
||||||
|
f"/api/v1/parcels/{_CAD}/analyze",
|
||||||
|
json={"weights": {"park": -5.0}},
|
||||||
|
)
|
||||||
|
assert resp2.status_code == 422, resp2.text
|
||||||
|
assert "park" in resp2.json()["detail"]
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
_stop_patches()
|
||||||
|
|
||||||
|
|
||||||
|
def test_analyze_inline_weights_beats_profile_id() -> None:
|
||||||
|
"""body.weights + profile_id → body.weights имеет приоритет (source = 'inline')."""
|
||||||
|
from app.core.db import get_db
|
||||||
|
|
||||||
|
db = _make_db_for_analyze()
|
||||||
|
app.dependency_overrides[get_db] = _override_db(db)
|
||||||
|
_start_patches()
|
||||||
|
try:
|
||||||
|
client = TestClient(app)
|
||||||
|
# Передаём и profile_id=1, и inline weights — inline должен победить
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/v1/parcels/{_CAD}/analyze?profile_id=1",
|
||||||
|
json={"weights": {"metro_stop": 2.0}},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
wp = resp.json()["weights_profile"]
|
||||||
|
assert wp["source"] == "inline", f"Ожидали source='inline', получили '{wp['source']}'"
|
||||||
|
assert wp["weights_applied"]["metro_stop"] == pytest.approx(2.0)
|
||||||
|
# profile_id всё ещё присутствует в ответе для трассировки
|
||||||
|
assert wp["profile_id"] == 1
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
_stop_patches()
|
||||||
|
|
@ -147,14 +147,18 @@ function SiteFinderContent() {
|
||||||
function handleAnalyze(cadNum: string) {
|
function handleAnalyze(cadNum: string) {
|
||||||
setIsochrones(undefined);
|
setIsochrones(undefined);
|
||||||
setTab("overview");
|
setTab("overview");
|
||||||
|
// Priority: named profile → user default profile → inline draft weights.
|
||||||
|
// When activeProfileId is set, backend uses that profile (ignores inline).
|
||||||
|
// When no profile is selected, pass currentWeights as inline so draft
|
||||||
|
// slider values are always respected even without a saved profile (#201).
|
||||||
mutate({
|
mutate({
|
||||||
cad: cadNum,
|
cad: cadNum,
|
||||||
options:
|
options:
|
||||||
activeProfileId != null
|
activeProfileId != null
|
||||||
? { profileId: activeProfileId }
|
? { profileId: activeProfileId }
|
||||||
: profileUserId
|
: profileUserId
|
||||||
? { profileUserId }
|
? { profileUserId, weights: currentWeights }
|
||||||
: undefined,
|
: { weights: currentWeights },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -163,10 +167,20 @@ function SiteFinderContent() {
|
||||||
profileId: number | null,
|
profileId: number | null,
|
||||||
) {
|
) {
|
||||||
setCurrentWeights(weights);
|
setCurrentWeights(weights);
|
||||||
// Store the active profile id so we can pass it to the analyze call.
|
|
||||||
// If user edited weights without saving a named profile, profileId=null
|
|
||||||
// and backend will use system defaults (sub-PR 5 would enable inline weights).
|
|
||||||
setActiveProfileId(profileId);
|
setActiveProfileId(profileId);
|
||||||
|
// Re-analyze with the new weights if a parcel is already loaded (#201).
|
||||||
|
if (data?.cad_num) {
|
||||||
|
setIsochrones(undefined);
|
||||||
|
mutate({
|
||||||
|
cad: data.cad_num,
|
||||||
|
options:
|
||||||
|
profileId != null
|
||||||
|
? { profileId }
|
||||||
|
: profileUserId
|
||||||
|
? { profileUserId, weights }
|
||||||
|
: { weights },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Derive KPI values from data
|
// Derive KPI values from data
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,11 @@ export interface AnalyzeOptions {
|
||||||
profileId?: number;
|
profileId?: number;
|
||||||
/** If set together with no profileId, backend uses user's default profile. */
|
/** If set together with no profileId, backend uses user's default profile. */
|
||||||
profileUserId?: string;
|
profileUserId?: string;
|
||||||
|
/**
|
||||||
|
* Inline POI weights override — sent as request body.
|
||||||
|
* Priority: inline → profileId → profileUserId default → system.
|
||||||
|
*/
|
||||||
|
weights?: Record<string, number> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -90,11 +95,23 @@ export function useSiteAnalysis() {
|
||||||
return qsStr ? `${base}?${qsStr}` : base;
|
return qsStr ? `${base}?${qsStr}` : base;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Build optional JSON body for inline weights (#201).
|
||||||
|
const bodyPayload =
|
||||||
|
options?.weights != null
|
||||||
|
? JSON.stringify({ weights: options.weights })
|
||||||
|
: undefined;
|
||||||
|
|
||||||
// First request — POST /analyze
|
// First request — POST /analyze
|
||||||
const first = await apiFetchWithStatus<
|
const first = await apiFetchWithStatus<
|
||||||
ParcelAnalysis | AnalyzeAcceptedResponse
|
ParcelAnalysis | AnalyzeAcceptedResponse
|
||||||
>(analyzeUrl(cad), {
|
>(analyzeUrl(cad), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
...(bodyPayload
|
||||||
|
? {
|
||||||
|
body: bodyPayload,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (first.status === 200) {
|
if (first.status === 200) {
|
||||||
|
|
@ -127,6 +144,12 @@ export function useSiteAnalysis() {
|
||||||
// mutation сразу резолвится с data — render skipped.
|
// mutation сразу резолвится с data — render skipped.
|
||||||
const second = await apiFetch<ParcelAnalysis>(analyzeUrl(cad), {
|
const second = await apiFetch<ParcelAnalysis>(analyzeUrl(cad), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
...(bodyPayload
|
||||||
|
? {
|
||||||
|
body: bodyPayload,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
setFetchingState(null);
|
setFetchingState(null);
|
||||||
return second;
|
return second;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue