feat(location): district Location entity + indices (#948 part B) #1165
10 changed files with 1236 additions and 0 deletions
58
backend/app/api/v1/locations.py
Normal file
58
backend/app/api/v1/locations.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
"""Read-only API для location (#948 Part B, ТЗ §8.2).
|
||||
|
||||
GET /api/v1/locations → LocationList (все локации с индексами)
|
||||
GET /api/v1/locations/{district_name} → LocationOut (одна; 404 если нет)
|
||||
|
||||
Read-only: индексы инфра/конкуренция/спрос/будущее-предложение ВЫЧИСЛЯЮТСЯ воркером
|
||||
(tasks/location_refresh.py через services/site_finder/locations.py), НЕ вводятся
|
||||
пользователем — поэтому CRUD нет (никаких POST/PUT/DELETE).
|
||||
|
||||
Access control: backend rbac_guard (app/main.py) хард-блокирует ТОЛЬКО /api/v1/admin/*
|
||||
(admin-only). На /api/v1/locations pilot отсекается FRONTEND-овым RouteGuard
|
||||
(allowed_paths из /me) + Caddy, НЕ бэкендом (is_path_allowed() из backend-кода тут НЕ
|
||||
вызывается). До эндпоинта доходят analyst+admin (та же модель, что insights/
|
||||
custom-pois — НЕ backend pilot-gating).
|
||||
|
||||
Хэндлеры sync `def` (зеркало insights.py/custom_pois.py): сервис ходит в БД через
|
||||
sync SQLAlchemy Session → FastAPI исполняет их в threadpool, event loop не блокируется
|
||||
(async def + sync db.execute было бы хуже — заблокировало бы loop).
|
||||
|
||||
district_name в path: FastAPI принимает Cyrillic через URL-encoding прозрачно (имена
|
||||
8 админ-районов ЕКБ: «Кировский», «Ленинский», …).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.schemas.location import LocationList, LocationOut
|
||||
from app.services.site_finder.locations import get_location, list_locations
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=LocationList)
|
||||
def list_locations_endpoint(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> Any:
|
||||
"""Все локации (районы) с district-level индексами."""
|
||||
return list_locations(db)
|
||||
|
||||
|
||||
@router.get("/{district_name}", response_model=LocationOut)
|
||||
def get_location_endpoint(
|
||||
district_name: str,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> Any:
|
||||
"""Вернуть одну локацию по district_name. 404 если не найдена."""
|
||||
location = get_location(db, district_name)
|
||||
if location is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Location not found")
|
||||
return location
|
||||
|
|
@ -29,6 +29,7 @@ from app.api.v1 import (
|
|||
custom_pois,
|
||||
insights,
|
||||
landing,
|
||||
locations,
|
||||
me,
|
||||
parcels,
|
||||
photos,
|
||||
|
|
@ -169,6 +170,7 @@ app.include_router(
|
|||
)
|
||||
app.include_router(photos.router, prefix="/api/v1/photos", tags=["photos"])
|
||||
app.include_router(custom_pois.router, prefix="/api/v1/custom-pois", tags=["custom-pois"])
|
||||
app.include_router(locations.router, prefix="/api/v1/locations", tags=["locations"])
|
||||
app.include_router(insights.router, prefix="/api/v1/insights", tags=["insights"])
|
||||
app.include_router(
|
||||
admin_cadastre.router,
|
||||
|
|
|
|||
68
backend/app/schemas/location.py
Normal file
68
backend/app/schemas/location.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
"""Pydantic schemas для location (#948 Part B, ТЗ §8.2).
|
||||
|
||||
LocationOut — ответ API: район + 4 district-level индекса (0..1 или None).
|
||||
LocationList — ответ list-эндпоинта (total + rows).
|
||||
|
||||
Read-only: индексы ВЫЧИСЛЯЮТСЯ воркером (tasks/location_refresh.py), не вводятся
|
||||
пользователем → Create/Update-схем нет (CRUD отсутствует, только GET-API).
|
||||
|
||||
Все индексы Optional[float] ∈ [0,1]: None = нет данных (graceful-on-thin-data —
|
||||
никогда не фабрикуем 0). centroid не отдаём бинарём — lon/lat достаточно для карты
|
||||
(NULL если centroid не вычислен).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class LocationOut(BaseModel):
|
||||
"""Локация (район) с district-level индексами (#948 Part B, §8.2)."""
|
||||
|
||||
id: int
|
||||
district_name: str = Field(..., description="Логический ключ района (8 админ-районов ЕКБ)")
|
||||
region: str | None = Field(None, description="Регион (NULL = ЕКБ/Свердл.)")
|
||||
lon: float | None = Field(None, description="Долгота центроида района (WGS-84), None если нет")
|
||||
lat: float | None = Field(None, description="Широта центроида района (WGS-84), None если нет")
|
||||
infra_index: float | None = Field(
|
||||
None,
|
||||
ge=0.0,
|
||||
le=1.0,
|
||||
description="Инфра-индекс 0..1 (POI района / POI города); None=нет данных",
|
||||
)
|
||||
competition_index: float | None = Field(
|
||||
None,
|
||||
ge=0.0,
|
||||
le=1.0,
|
||||
description=(
|
||||
"Индекс конкуренции 0..1 (доступное/затоваренное конкурирующее предложение "
|
||||
"= overstock; выше = больше конкур. давления; ортогонален demand); "
|
||||
"None=нет данных"
|
||||
),
|
||||
)
|
||||
demand_index: float | None = Field(
|
||||
None,
|
||||
ge=0.0,
|
||||
le=1.0,
|
||||
description="Индекс спроса 0..1 (saturated velocity); None=нет данных",
|
||||
)
|
||||
future_supply_index: float | None = Field(
|
||||
None,
|
||||
ge=0.0,
|
||||
le=1.0,
|
||||
description="Индекс будущего предложения 0..1; None=нет данных",
|
||||
)
|
||||
indices_computed_at: datetime | None = Field(
|
||||
None, description="Когда индексы последний раз пересчитаны (None до первого refresh)"
|
||||
)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class LocationList(BaseModel):
|
||||
"""Ответ list-эндпоинта: total + страница строк (зеркало InsightList)."""
|
||||
|
||||
total: int
|
||||
rows: list[LocationOut]
|
||||
435
backend/app/services/site_finder/locations.py
Normal file
435
backend/app/services/site_finder/locations.py
Normal file
|
|
@ -0,0 +1,435 @@
|
|||
"""Location-сервис — district-level индексы первоклассной «локации» (#948 Part B, §8.2).
|
||||
|
||||
Промоутит район (district) в queryable сущность `location` (data/sql/146_location.sql),
|
||||
несущую 4 нормированных индекса:
|
||||
• infra — инфраструктура района
|
||||
• competition — конкуренция (доступное/затоваренное конкурирующее предложение)
|
||||
• demand — спрос (темп продаж)
|
||||
• future_supply — будущее давление предложения
|
||||
|
||||
ADDITIVE (HARD CONSTRAINT #948B): `district` (string) НЕ рефакторится в FK. Этот
|
||||
сервис — НОВЫЙ слой РЯДОМ: ключует `location` по `district_name` (joinable по строке),
|
||||
переиспользуя существующие per-district forecast-функции (они #1129-кэшированы, вызов
|
||||
per-district дёшев). Существующий код продолжает ходить по district-строкам.
|
||||
|
||||
ИСТОЧНИКИ ИНДЕКСОВ (reuse, не изобретаем) + НОРМАЛИЗАЦИЯ к [0,1] (None при отсутствии
|
||||
данных — НИКОГДА не фабрикуем 0, дух market_metrics.py / future_supply.py):
|
||||
|
||||
• competition ← market_metrics.compute_market_metrics(db, district=…).overstock_index
|
||||
overstock_index = долго-экспонируемый непроданный сток / все доступные ед.
|
||||
∈ [0,1] — доля «зависшего» конкурирующего предложения (в продаже > N мес без
|
||||
сделки). Это ИСТИННЫЙ сигнал конкуренции = доступное/затоваренное конкурирующее
|
||||
предложение: выше = больше конкурирующего стока простаивает = БОЛЬШЕ
|
||||
конкурентного давления для нового входящего игрока. УЖЕ нормирован [0,1]
|
||||
(доля от доступных) — берём как есть, без пере-нормировки. None когда метрика
|
||||
None (нет доступных лотов / пустая выборка).
|
||||
|
||||
ОРТОГОНАЛЬНОСТЬ к demand: competition мерит ДОСТУПНОЕ/непроданное предложение,
|
||||
demand — скорость продаж (unit_velocity). Это разные оси (saturation supply vs
|
||||
sales velocity), не монотонно связанные. Раньше тут стоял sell_through_pct
|
||||
(доля реализованного стока) — но это absorption/heat-сигнал, монотонно
|
||||
растущий с продажами (коррелирован с demand=velocity) И инвертированный
|
||||
относительно «свободного конкурирующего стока»: высокий sell-through =
|
||||
МЕНЬШЕ доступной конкуренции, а не больше. overstock_index — честная,
|
||||
ортогональная спросу замена.
|
||||
|
||||
• demand ← market_metrics.compute_market_metrics(db, district=…).unit_velocity
|
||||
unit_velocity = ед., проданных в месяц (за окно) ∈ [0, ∞). Это ТОТ ЖЕ сигнал
|
||||
спроса, что использует forecasting/demand_supply_forecast (base_pace). Нет
|
||||
естественного потолка → насыщающее преобразование (зеркало
|
||||
future_supply._saturating_index): min(1.0, velocity / _DEMAND_SATURATION_UPM).
|
||||
_DEMAND_SATURATION_UPM = 50 ед./мес — район, продающий ≥50 квартир/мес,
|
||||
считаем «максимальный спрос» (index=1.0). None когда unit_velocity None.
|
||||
|
||||
• future_supply ← future_supply.compute_future_supply_pressure(db, district=…).index
|
||||
УЖЕ нормирован ∈ [0,1] самим сервисом (насыщение months_of_pressure). Берём
|
||||
как есть — НЕ пере-нормируем. None когда index None (нет supply-данных /
|
||||
поглощения).
|
||||
|
||||
• infra ← analytics_queries._district_poi_score(db, district_name) /
|
||||
analytics_queries._city_avg_poi_score(db)
|
||||
_district_poi_score = средний по ЖК района weighted POI-count (метро/медицина
|
||||
тяжелее) в радиусе 1000м; _city_avg_poi_score — то же по всему ЕКБ (готовый
|
||||
нормализатор, уже существует). Нормализация: district / city, clamp [0,1] —
|
||||
район со средней-по-городу инфраструктурой ≈ 0.5..1.0, лучше города → clamp в
|
||||
1.0. Это РЕАЛЬНЫЙ per-district infra-сигнал (НЕ NULL-deferred). None когда в
|
||||
районе <3 ЖК с POI (_district_poi_score вернул None) ИЛИ нет city-baseline.
|
||||
|
||||
DISTRICT SOURCE: 8 официальных АДМИН-районов ЕКБ — через
|
||||
district_resolver._admin_names(db) (готовый кэшированный запрос
|
||||
`SELECT district_name FROM ekb_districts WHERE geom IS NOT NULL`; 'не определён' там
|
||||
нет — и не должно быть). Это канонический district-вокабуляр для наполнения локаций.
|
||||
|
||||
CENTROID: derivable из ekb_districts_geom (PostGIS-полигоны, миграция 56) через
|
||||
ST_Centroid(geom) — наполняется в refresh_locations прямым SQL (район может не иметь
|
||||
geom-строки → centroid остаётся NULL, graceful).
|
||||
|
||||
Conventions: psycopg v3 CAST(:x AS type) — НИКОГДА :x::type; SAVEPOINT per-row в
|
||||
refresh-цикле (backend.md); logger не print; graceful — сбой одного района не валит
|
||||
остальные. Детерминированно, без LLM.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.schemas.location import LocationList, LocationOut
|
||||
from app.services.analytics_queries import _city_avg_poi_score, _district_poi_score
|
||||
from app.services.site_finder.district_resolver import _admin_names
|
||||
from app.services.site_finder.future_supply import compute_future_supply_pressure
|
||||
from app.services.site_finder.market_metrics import compute_market_metrics
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Насыщение demand-индекса: unit_velocity (ед./мес), при котором index достигает 1.0.
|
||||
# 50 квартир/мес по району — очень высокий темп для ЕКБ (зеркало дисциплины насыщения
|
||||
# future_supply._PRESSURE_SATURATION_MONTHS). Tunable. Линейный clamp интерпретируем:
|
||||
# index=0.5 ⇔ 25 ед./мес.
|
||||
_DEMAND_SATURATION_UPM: float = 50.0
|
||||
|
||||
# Окно метрик рынка (мес) — совпадает с дефолтом compute_market_metrics (§9.2).
|
||||
_MARKET_WINDOW_MONTHS: int = 6
|
||||
|
||||
# Горизонт будущего предложения (мес) — дефолт compute_future_supply_pressure (§9.3).
|
||||
_FUTURE_SUPPLY_HORIZON_MONTHS: int = 12
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LocationIndices:
|
||||
"""4 district-level индекса (#948 §8.2). Каждый ∈ [0,1] или None (нет данных).
|
||||
|
||||
None НИКОГДА не подменяется 0 — «нет данных» и «честный ноль» обязаны различаться
|
||||
(дух market_metrics.py graceful-on-thin-data).
|
||||
"""
|
||||
|
||||
infra_index: float | None
|
||||
competition_index: float | None
|
||||
demand_index: float | None
|
||||
future_supply_index: float | None
|
||||
|
||||
def as_dict(self) -> dict[str, float | None]:
|
||||
return {
|
||||
"infra_index": _round_or_none(self.infra_index),
|
||||
"competition_index": _round_or_none(self.competition_index),
|
||||
"demand_index": _round_or_none(self.demand_index),
|
||||
"future_supply_index": _round_or_none(self.future_supply_index),
|
||||
}
|
||||
|
||||
|
||||
def _round_or_none(value: float | None, digits: int = 4) -> float | None:
|
||||
return round(value, digits) if value is not None else None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Pure-нормализация — без БД, полностью юнит-тестируемо.
|
||||
# Каждая функция graceful: None на входе → None на выходе (не 0).
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _clamp01(value: float) -> float:
|
||||
"""Зажать в [0,1]. PURE."""
|
||||
return max(0.0, min(1.0, value))
|
||||
|
||||
|
||||
def normalize_competition(overstock_index: float | None) -> float | None:
|
||||
"""Индекс конкуренции = доступное/затоваренное конкурирующее предложение → [0,1]. PURE.
|
||||
|
||||
Источник — market_metrics.overstock_index = долго-экспонируемый непроданный сток /
|
||||
все доступные ед. ∈ [0,1]: доля «зависшего» конкурирующего предложения. Выше =
|
||||
больше конкурирующего стока простаивает = БОЛЬШЕ конкурентного давления для нового
|
||||
входящего игрока. Это ИСТИННЫЙ сигнал конкуренции и он ОРТОГОНАЛЕН demand (скорости
|
||||
продаж = unit_velocity), а не коррелирован с ним (как был старый sell_through).
|
||||
|
||||
Уже нормирован [0,1] самим market_metrics (доля от доступных) — только clamp как
|
||||
защита от артефактов. None → None (нет данных / нет доступных лотов, НЕ 0 — иначе
|
||||
«нет конкурирующего стока для замера» неотличимо от «честный ноль затоваривания»).
|
||||
"""
|
||||
if overstock_index is None:
|
||||
return None
|
||||
return _clamp01(overstock_index)
|
||||
|
||||
|
||||
def normalize_demand(
|
||||
unit_velocity: float | None,
|
||||
*,
|
||||
saturation_upm: float = _DEMAND_SATURATION_UPM,
|
||||
) -> float | None:
|
||||
"""Индекс спроса из unit_velocity (ед./мес ∈ [0,∞)) → [0,1]. PURE.
|
||||
|
||||
Насыщающее преобразование (зеркало future_supply._saturating_index): линейный
|
||||
clamp min(1.0, velocity / saturation_upm). Монотонно неубывающее, насыщается в 1.0
|
||||
на saturation_upm. None → None. velocity=0.0 (валидное измерение «0 продаж/мес») →
|
||||
0.0 (а не None — это честный ноль спроса, market_metrics уже отличил его от
|
||||
None-«нет выборки»).
|
||||
"""
|
||||
if unit_velocity is None:
|
||||
return None
|
||||
if saturation_upm <= 0:
|
||||
# Деградация без падения: нулевой масштаб → любой положительный спрос = max.
|
||||
return 1.0 if unit_velocity > 0 else 0.0
|
||||
return _clamp01(unit_velocity / saturation_upm)
|
||||
|
||||
|
||||
def normalize_infra(
|
||||
district_poi: float | None,
|
||||
city_avg_poi: float | None,
|
||||
) -> float | None:
|
||||
"""Инфра-индекс из POI района относительно POI города → [0,1]. PURE.
|
||||
|
||||
district_poi / city_avg_poi, clamp [0,1]: район со средней-по-городу
|
||||
инфраструктурой ≈ 1.0 (но clamp удержит лучше-города в 1.0; район беднее города
|
||||
→ <1.0). None если district_poi None (в районе <3 ЖК с POI) ИЛИ city-baseline
|
||||
None/≤0 (нет нормализатора). Никогда не 0-как-заглушка.
|
||||
"""
|
||||
if district_poi is None or city_avg_poi is None or city_avg_poi <= 0:
|
||||
return None
|
||||
return _clamp01(district_poi / city_avg_poi)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# DB-оркестратор индексов — тонкий, graceful. Pure-нормализация выше тестируется без БД.
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def compute_location_indices(db: Session, district: str) -> LocationIndices:
|
||||
"""Вычислить 4 district-level индекса для района (#948 §8.2).
|
||||
|
||||
Переиспользует существующие per-district forecast-функции (#1129-кэшированы) и
|
||||
нормализует каждый результат к [0,1] (None при отсутствии данных — см. модульный
|
||||
docstring про нормализацию каждого индекса). НИКОГДА не фабрикует 0.
|
||||
|
||||
Graceful: если источник бросает — соответствующий индекс = None (logged), остальные
|
||||
считаются. Никогда не валит весь расчёт из-за одного сбойного сигнала.
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy sync Session.
|
||||
district: имя района (одно из 8 админ-районов ЕКБ).
|
||||
|
||||
Returns:
|
||||
LocationIndices (всегда; индекс=None там, где данных нет / источник сбоил).
|
||||
"""
|
||||
competition_index: float | None = None
|
||||
demand_index: float | None = None
|
||||
future_supply_index: float | None = None
|
||||
infra_index: float | None = None
|
||||
|
||||
# ── competition + demand ← market_metrics (один вызов, два сигнала) ──────────
|
||||
try:
|
||||
metrics = compute_market_metrics(
|
||||
db, district=district, window_months=_MARKET_WINDOW_MONTHS
|
||||
)
|
||||
# competition ← overstock_index (доступное/затоваренное конкурирующее
|
||||
# предложение) — ортогонален demand. НЕ sell_through (тот коррелирован с
|
||||
# velocity и инвертирован относительно «свободной конкуренции»).
|
||||
competition_index = normalize_competition(metrics.overstock_index)
|
||||
demand_index = normalize_demand(metrics.unit_velocity)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"location_indices: market_metrics failed (district=%s) → competition/demand None",
|
||||
district,
|
||||
)
|
||||
|
||||
# ── future_supply ← future_supply (уже 0..1) ────────────────────────────────
|
||||
try:
|
||||
fsp = compute_future_supply_pressure(
|
||||
db, district=district, horizon_months=_FUTURE_SUPPLY_HORIZON_MONTHS
|
||||
)
|
||||
# index уже нормирован [0,1] самим сервисом — берём как есть (None passthrough).
|
||||
future_supply_index = fsp.index
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"location_indices: future_supply failed (district=%s) → future_supply None",
|
||||
district,
|
||||
)
|
||||
|
||||
# ── infra ← district POI / city POI ─────────────────────────────────────────
|
||||
try:
|
||||
district_poi = _district_poi_score(db, district_name=district)
|
||||
city_poi = _city_avg_poi_score(db)
|
||||
infra_index = normalize_infra(district_poi, city_poi)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"location_indices: infra (POI) failed (district=%s) → infra None", district
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"location_indices: district=%s infra=%s competition=%s demand=%s future_supply=%s",
|
||||
district,
|
||||
_round_or_none(infra_index),
|
||||
_round_or_none(competition_index),
|
||||
_round_or_none(demand_index),
|
||||
_round_or_none(future_supply_index),
|
||||
)
|
||||
|
||||
return LocationIndices(
|
||||
infra_index=infra_index,
|
||||
competition_index=competition_index,
|
||||
demand_index=demand_index,
|
||||
future_supply_index=future_supply_index,
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Refresh — upsert индексов по всем районам (SAVEPOINT per-row, CAST, ON CONFLICT).
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# centroid derive из ekb_districts_geom: ST_Centroid полигона района. Подзапрос —
|
||||
# район может не иметь geom-строки → centroid NULL (graceful). ON CONFLICT по
|
||||
# district_name (UNIQUE) обновляет индексы + centroid + indices_computed_at.
|
||||
# psycopg v3: ВСЕ binds через CAST(:x AS type) — НИКОГДА :x::type.
|
||||
_UPSERT_LOCATION_SQL = text(
|
||||
"""
|
||||
INSERT INTO location (
|
||||
district_name, region, centroid,
|
||||
infra_index, competition_index, demand_index, future_supply_index,
|
||||
indices_computed_at, updated_at
|
||||
) VALUES (
|
||||
CAST(:district_name AS text),
|
||||
CAST(:region AS text),
|
||||
(
|
||||
SELECT ST_Centroid(g.geom)
|
||||
FROM ekb_districts_geom g
|
||||
WHERE g.district_name = CAST(:district_name AS text)
|
||||
LIMIT 1
|
||||
),
|
||||
CAST(:infra_index AS numeric),
|
||||
CAST(:competition_index AS numeric),
|
||||
CAST(:demand_index AS numeric),
|
||||
CAST(:future_supply_index AS numeric),
|
||||
now(),
|
||||
now()
|
||||
)
|
||||
ON CONFLICT (district_name) DO UPDATE SET
|
||||
region = EXCLUDED.region,
|
||||
centroid = EXCLUDED.centroid,
|
||||
infra_index = EXCLUDED.infra_index,
|
||||
competition_index = EXCLUDED.competition_index,
|
||||
demand_index = EXCLUDED.demand_index,
|
||||
future_supply_index = EXCLUDED.future_supply_index,
|
||||
indices_computed_at = EXCLUDED.indices_computed_at,
|
||||
updated_at = now()
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def refresh_locations(db: Session, *, region: str | None = None) -> dict[str, Any]:
|
||||
"""Пересчитать + upsert-нуть индексы по всем 8 админ-районам ЕКБ (#948 §8.2).
|
||||
|
||||
Итерирует district-источник (district_resolver._admin_names → ekb_districts),
|
||||
для каждого считает индексы (compute_location_indices) и апсертит в `location`.
|
||||
Идемпотентно: ON CONFLICT (district_name) обновляет индексы + centroid +
|
||||
indices_computed_at (повторный прогон даёт тот же результат при тех же данных).
|
||||
|
||||
SAVEPOINT per-row (backend.md): сбойный район откатывается изолированно через
|
||||
begin_nested, не роняя всю транзакцию и не сбивая счётчики (#261/pzz_loader bug).
|
||||
db.commit() один раз в конце.
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy sync Session.
|
||||
region: метка региона в колонку `location.region` (None = ЕКБ/Свердл.).
|
||||
|
||||
Returns:
|
||||
Счётчики: {"districts": N, "upserted": M, "failed": K}.
|
||||
"""
|
||||
districts = sorted(_admin_names(db))
|
||||
if not districts:
|
||||
logger.warning(
|
||||
"refresh_locations: district source empty (ekb_districts geom?) — nothing to refresh"
|
||||
)
|
||||
return {"districts": 0, "upserted": 0, "failed": 0}
|
||||
|
||||
upserted = 0
|
||||
failed = 0
|
||||
for district in districts:
|
||||
try:
|
||||
indices = compute_location_indices(db, district)
|
||||
with db.begin_nested():
|
||||
db.execute(
|
||||
_UPSERT_LOCATION_SQL,
|
||||
{
|
||||
"district_name": district,
|
||||
"region": region,
|
||||
"infra_index": indices.infra_index,
|
||||
"competition_index": indices.competition_index,
|
||||
"demand_index": indices.demand_index,
|
||||
"future_supply_index": indices.future_supply_index,
|
||||
},
|
||||
)
|
||||
upserted += 1
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
logger.warning("refresh_locations: district=%s upsert failed: %s", district, e)
|
||||
|
||||
db.commit()
|
||||
logger.info(
|
||||
"refresh_locations: districts=%d upserted=%d failed=%d",
|
||||
len(districts),
|
||||
upserted,
|
||||
failed,
|
||||
)
|
||||
return {"districts": len(districts), "upserted": upserted, "failed": failed}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Read-helpers для GET-API (read-only сущность — без CRUD).
|
||||
# centroid не отдаём бинарём: ST_X/ST_Y → lon/lat (NULL если centroid NULL).
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
_SELECT_COLS = """
|
||||
id, district_name, region,
|
||||
ST_X(centroid) AS lon, ST_Y(centroid) AS lat,
|
||||
infra_index, competition_index, demand_index, future_supply_index,
|
||||
indices_computed_at, created_at, updated_at
|
||||
"""
|
||||
|
||||
|
||||
def _row_to_out(r: Any) -> LocationOut:
|
||||
return LocationOut(
|
||||
id=int(r["id"]),
|
||||
district_name=r["district_name"],
|
||||
region=r["region"],
|
||||
lon=float(r["lon"]) if r["lon"] is not None else None,
|
||||
lat=float(r["lat"]) if r["lat"] is not None else None,
|
||||
infra_index=float(r["infra_index"]) if r["infra_index"] is not None else None,
|
||||
competition_index=(
|
||||
float(r["competition_index"]) if r["competition_index"] is not None else None
|
||||
),
|
||||
demand_index=float(r["demand_index"]) if r["demand_index"] is not None else None,
|
||||
future_supply_index=(
|
||||
float(r["future_supply_index"]) if r["future_supply_index"] is not None else None
|
||||
),
|
||||
indices_computed_at=r["indices_computed_at"],
|
||||
created_at=r["created_at"],
|
||||
updated_at=r["updated_at"],
|
||||
)
|
||||
|
||||
|
||||
def list_locations(db: Any) -> LocationList:
|
||||
"""Все локации с индексами (8 районов — пагинация не нужна, отдаём целиком)."""
|
||||
rows = (
|
||||
db.execute(text(f"SELECT {_SELECT_COLS} FROM location ORDER BY district_name"))
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
out = [_row_to_out(r) for r in rows]
|
||||
return LocationList(total=len(out), rows=out)
|
||||
|
||||
|
||||
def get_location(db: Any, district_name: str) -> LocationOut | None:
|
||||
"""Вернуть одну локацию по district_name. None если нет (роутер → 404)."""
|
||||
row = (
|
||||
db.execute(
|
||||
text(f"SELECT {_SELECT_COLS} FROM location WHERE district_name = :dn"),
|
||||
{"dn": district_name},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
if row is None:
|
||||
return None
|
||||
return _row_to_out(row)
|
||||
|
|
@ -449,4 +449,17 @@ def build_beat_schedule() -> dict:
|
|||
"options": {"queue": "celery"},
|
||||
}
|
||||
|
||||
# District-level индексы локаций (#948 Part B, §8.2) → location (м.146): reuse
|
||||
# per-district forecast-функций (market_metrics/future_supply/POI), нормализация
|
||||
# 0..1, upsert по 8 районам ЕКБ. Понедельник 07:00 МСК — ПОСЛЕ supply-layers
|
||||
# (06:00) и genplan (06:30), чтобы future_supply-индекс считался по СВЕЖЕМУ складу
|
||||
# предложения. Индексы меняются медленно (рынок поглощает сток неделями) —
|
||||
# еженедельно достаточно. ON CONFLICT идемпотентен. Техническая, не в job_settings
|
||||
# (как cbr/supply-layers). Оператор может перенести расписание правкой crontab.
|
||||
schedule["location-refresh-weekly"] = {
|
||||
"task": "tasks.location_refresh.location_refresh",
|
||||
"schedule": _parse_cron("0 7 * * mon"),
|
||||
"options": {"queue": "celery"},
|
||||
}
|
||||
|
||||
return schedule
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ celery_app = Celery(
|
|||
"app.workers.tasks.cbr_macro_sync",
|
||||
"app.workers.tasks.rosstat_macro_sync",
|
||||
"app.workers.tasks.supply_layers_refresh",
|
||||
"app.workers.tasks.location_refresh",
|
||||
"app.workers.tasks.forecast",
|
||||
"app.workers.tasks.ird_harvest",
|
||||
"app.workers.tasks.ekb_krt_sync",
|
||||
|
|
|
|||
61
backend/app/workers/tasks/location_refresh.py
Normal file
61
backend/app/workers/tasks/location_refresh.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
"""Celery task: пересчёт district-level индексов локаций → `location` (#948 Part B, §8.2).
|
||||
|
||||
Прогоняет refresh_locations (app/services/site_finder/locations.py): итерирует 8
|
||||
админ-районов ЕКБ, для каждого считает индексы инфра/конкуренция/спрос/будущее-
|
||||
предложение (reuse per-district forecast-функций, #1129-кэшированы) и апсертит в
|
||||
`location`. ON CONFLICT (district_name) делает прогон идемпотентным.
|
||||
|
||||
Расписание — еженедельно (district-индексы меняются медленно: рынок поглощает сток
|
||||
неделями, будущее предложение — слой supply-layers, который сам обновляется
|
||||
еженедельно по понедельникам). Регистрируется в beat_schedule.py ПОСЛЕ supply-layers
|
||||
(Mon 06:00) — чтобы future_supply-индекс считался по СВЕЖЕМУ складу предложения.
|
||||
|
||||
Детерминированно, без LLM. SAVEPOINT per-row внутри refresh_locations (backend.md):
|
||||
сбойный район не валит остальные.
|
||||
|
||||
Mirror conventions (cbr_macro_sync / supply_layers_refresh): SessionLocal() +
|
||||
try/finally close, logger (не print), задача техническая — не управляется через
|
||||
job_settings, добавляется в beat_schedule hardcoded-блок.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
from app.services.site_finder.locations import refresh_locations
|
||||
from app.workers.celery_app import celery_app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
bind=True,
|
||||
name="tasks.location_refresh.location_refresh",
|
||||
max_retries=2,
|
||||
)
|
||||
def location_refresh(self: Any, region: str | None = None) -> dict[str, Any]:
|
||||
"""Пересчитать + upsert-нуть district-level индексы по всем районам в `location`.
|
||||
|
||||
Идемпотентно (ON CONFLICT по district_name). Graceful: сбойный район
|
||||
откатывается изолированно (SAVEPOINT), не роняя прогон.
|
||||
|
||||
Args:
|
||||
region: метка региона в колонку location.region (None = ЕКБ/Свердл.).
|
||||
|
||||
Returns:
|
||||
Счётчики refresh_locations: {"districts": N, "upserted": M, "failed": K}.
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
result = refresh_locations(db, region=region)
|
||||
logger.info(
|
||||
"location_refresh: districts=%s upserted=%s failed=%s",
|
||||
result["districts"],
|
||||
result["upserted"],
|
||||
result["failed"],
|
||||
)
|
||||
return result
|
||||
finally:
|
||||
db.close()
|
||||
162
backend/tests/api/v1/test_locations.py
Normal file
162
backend/tests/api/v1/test_locations.py
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
"""Тесты read-only API для location (#948 Part B, ТЗ §8.2).
|
||||
|
||||
Покрывает:
|
||||
1. GET /api/v1/locations → LocationList (total + rows с индексами)
|
||||
2. GET /api/v1/locations/{district_name} → 200 (одна локация)
|
||||
3. GET /api/v1/locations/{district_name} → 404 если нет
|
||||
4. Индексы-None (нет данных) корректно сериализуются как null
|
||||
5. Нет write-методов: POST/PUT/DELETE → 405 (read-only сущность)
|
||||
|
||||
Стратегия mock: сервисные функции (list_locations / get_location) патчим через
|
||||
unittest.mock.patch; get_db переопределяем заглушкой (сервис запатчен — БД не
|
||||
трогается). settings.testing=True (conftest) отключает rbac_guard.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
from app.schemas.location import LocationList, LocationOut
|
||||
|
||||
_TS = datetime(2026, 6, 7, 10, 0, 0, tzinfo=UTC)
|
||||
_DISTRICT = "Кировский"
|
||||
|
||||
|
||||
def _make_out(
|
||||
district_name: str = _DISTRICT,
|
||||
infra: float | None = 0.82,
|
||||
competition: float | None = 0.6,
|
||||
demand: float | None = 0.5,
|
||||
future_supply: float | None = 0.3,
|
||||
lon: float | None = 60.6,
|
||||
lat: float | None = 56.84,
|
||||
) -> LocationOut:
|
||||
return LocationOut(
|
||||
id=1,
|
||||
district_name=district_name,
|
||||
region=None,
|
||||
lon=lon,
|
||||
lat=lat,
|
||||
infra_index=infra,
|
||||
competition_index=competition,
|
||||
demand_index=demand,
|
||||
future_supply_index=future_supply,
|
||||
indices_computed_at=_TS,
|
||||
created_at=_TS,
|
||||
updated_at=_TS,
|
||||
)
|
||||
|
||||
|
||||
def _override_db():
|
||||
"""Заглушка get_db — сервис запатчен, реальная сессия не нужна."""
|
||||
|
||||
def _get_db_override():
|
||||
yield MagicMock()
|
||||
|
||||
return _get_db_override
|
||||
|
||||
|
||||
# ── 1. LIST ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_list_locations_returns_list_with_indices() -> None:
|
||||
from app.core.db import get_db
|
||||
|
||||
expected = LocationList(
|
||||
total=2,
|
||||
rows=[_make_out("Кировский"), _make_out("Ленинский", infra=None)],
|
||||
)
|
||||
app.dependency_overrides[get_db] = _override_db()
|
||||
with patch("app.api.v1.locations.list_locations", return_value=expected) as mock_list:
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/v1/locations")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["total"] == 2
|
||||
assert len(body["rows"]) == 2
|
||||
assert body["rows"][0]["district_name"] == "Кировский"
|
||||
assert body["rows"][0]["competition_index"] == 0.6
|
||||
# None-индекс сериализуется как null (нет данных, не 0).
|
||||
assert body["rows"][1]["infra_index"] is None
|
||||
mock_list.assert_called_once()
|
||||
|
||||
|
||||
def test_list_locations_empty() -> None:
|
||||
from app.core.db import get_db
|
||||
|
||||
app.dependency_overrides[get_db] = _override_db()
|
||||
with patch("app.api.v1.locations.list_locations", return_value=LocationList(total=0, rows=[])):
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/v1/locations")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json() == {"total": 0, "rows": []}
|
||||
|
||||
|
||||
# ── 2 + 3. GET BY NAME ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_get_location_by_name_returns_200() -> None:
|
||||
from app.core.db import get_db
|
||||
|
||||
app.dependency_overrides[get_db] = _override_db()
|
||||
with patch("app.api.v1.locations.get_location", return_value=_make_out()) as mock_get:
|
||||
client = TestClient(app)
|
||||
resp = client.get(f"/api/v1/locations/{_DISTRICT}")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["district_name"] == _DISTRICT
|
||||
assert body["demand_index"] == 0.5
|
||||
assert body["future_supply_index"] == 0.3
|
||||
assert body["lon"] == 60.6
|
||||
# district_name пробрасывается из path в сервис.
|
||||
assert mock_get.call_args[0][1] == _DISTRICT
|
||||
|
||||
|
||||
def test_get_location_unknown_returns_404() -> None:
|
||||
from app.core.db import get_db
|
||||
|
||||
app.dependency_overrides[get_db] = _override_db()
|
||||
with patch("app.api.v1.locations.get_location", return_value=None):
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/v1/locations/НесуществующийРайон")
|
||||
assert resp.status_code == 404, resp.text
|
||||
assert resp.json()["detail"] == "Location not found"
|
||||
|
||||
|
||||
def test_get_location_null_indices_serialize_as_null() -> None:
|
||||
from app.core.db import get_db
|
||||
|
||||
app.dependency_overrides[get_db] = _override_db()
|
||||
out = _make_out(
|
||||
infra=None, competition=None, demand=None, future_supply=None, lon=None, lat=None
|
||||
)
|
||||
with patch("app.api.v1.locations.get_location", return_value=out):
|
||||
client = TestClient(app)
|
||||
resp = client.get(f"/api/v1/locations/{_DISTRICT}")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["infra_index"] is None
|
||||
assert body["competition_index"] is None
|
||||
assert body["demand_index"] is None
|
||||
assert body["future_supply_index"] is None
|
||||
assert body["lon"] is None
|
||||
|
||||
|
||||
# ── 5. READ-ONLY: no write methods ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_post_location_not_allowed() -> None:
|
||||
"""Локации вычисляются, не вводятся → POST не зарегистрирован (405)."""
|
||||
client = TestClient(app)
|
||||
resp = client.post("/api/v1/locations", json={"district_name": "Кировский"})
|
||||
assert resp.status_code == 405
|
||||
|
||||
|
||||
def test_delete_location_not_allowed() -> None:
|
||||
client = TestClient(app)
|
||||
resp = client.delete(f"/api/v1/locations/{_DISTRICT}")
|
||||
assert resp.status_code == 405
|
||||
327
backend/tests/services/site_finder/test_locations.py
Normal file
327
backend/tests/services/site_finder/test_locations.py
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
"""Тесты location-сервиса (#948 Part B, ТЗ §8.2).
|
||||
|
||||
Покрывает:
|
||||
1. Pure-нормализация каждого индекса → [0,1] + None-on-no-data (никогда 0-как-заглушка):
|
||||
normalize_competition / normalize_demand / normalize_infra.
|
||||
2. compute_location_indices: мокаем 4 source-функции (compute_market_metrics /
|
||||
compute_future_supply_pressure / _district_poi_score / _city_avg_poi_score) →
|
||||
assert нормализованные значения + None когда источник вернул None/сбоил.
|
||||
3. refresh_locations: idempotent upsert по district-источнику (мок _admin_names),
|
||||
SAVEPOINT per-row, счётчики; пустой источник → no-op.
|
||||
|
||||
Стратегия mock: source-функции патчим через unittest.mock.patch, DB — MagicMock
|
||||
(сервис ходит в БД только через эти запатченные функции + upsert SQL, который на
|
||||
MagicMock-сессии ничего не делает). Детерминированно, без LLM, без реальной БД.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.services.site_finder.locations import (
|
||||
_DEMAND_SATURATION_UPM,
|
||||
compute_location_indices,
|
||||
normalize_competition,
|
||||
normalize_demand,
|
||||
normalize_infra,
|
||||
refresh_locations,
|
||||
)
|
||||
|
||||
# Пути патча — модуль locations импортирует функции по имени, патчим в его namespace.
|
||||
_MM = "app.services.site_finder.locations.compute_market_metrics"
|
||||
_FSP = "app.services.site_finder.locations.compute_future_supply_pressure"
|
||||
_DPOI = "app.services.site_finder.locations._district_poi_score"
|
||||
_CPOI = "app.services.site_finder.locations._city_avg_poi_score"
|
||||
_ADMIN = "app.services.site_finder.locations._admin_names"
|
||||
|
||||
|
||||
def _metrics(
|
||||
overstock_index: float | None,
|
||||
unit_velocity: float | None,
|
||||
) -> SimpleNamespace:
|
||||
"""Минимальный stand-in MarketMetrics — только поля, которые читает сервис.
|
||||
|
||||
competition берётся из overstock_index (доступное/затоваренное конкурирующее
|
||||
предложение, ортогонально demand), demand — из unit_velocity. Это РАЗНЫЕ поля
|
||||
источника: тесты ниже опираются на их независимость.
|
||||
"""
|
||||
return SimpleNamespace(overstock_index=overstock_index, unit_velocity=unit_velocity)
|
||||
|
||||
|
||||
def _fsp(index: float | None) -> SimpleNamespace:
|
||||
"""Минимальный stand-in FutureSupplyPressure — только .index."""
|
||||
return SimpleNamespace(index=index)
|
||||
|
||||
|
||||
# ── 1. Pure-нормализация: competition ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestNormalizeCompetition:
|
||||
# Источник — overstock_index ∈ [0,1] (доля долго-непроданного конкурирующего
|
||||
# стока = доступное/затоваренное конкурирующее предложение). Уже нормирован,
|
||||
# только clamp. Выше = больше конкурентного давления для нового игрока.
|
||||
def test_mid_value_passthrough(self) -> None:
|
||||
# 0.6 затоваривания → competition 0.6 (НЕ делим на 100 — источник уже [0,1]).
|
||||
assert normalize_competition(0.6) == 0.6
|
||||
|
||||
def test_zero_is_zero_not_none(self) -> None:
|
||||
# 0 затоваривания — честный ноль (выборка была, сток весь свежий), НЕ None.
|
||||
assert normalize_competition(0.0) == 0.0
|
||||
|
||||
def test_full_is_one(self) -> None:
|
||||
# Весь доступный сток «завис» → максимум конкурентного давления.
|
||||
assert normalize_competition(1.0) == 1.0
|
||||
|
||||
def test_above_one_clamps(self) -> None:
|
||||
# Защита от артефактов > 1.0 → clamp в 1.0.
|
||||
assert normalize_competition(1.4) == 1.0
|
||||
|
||||
def test_none_passthrough(self) -> None:
|
||||
# Нет данных / нет доступных лотов → None (НЕ 0).
|
||||
assert normalize_competition(None) is None
|
||||
|
||||
|
||||
# ── 1. Pure-нормализация: demand (saturating) ──────────────────────────────────
|
||||
|
||||
|
||||
class TestNormalizeDemand:
|
||||
def test_half_saturation_is_half(self) -> None:
|
||||
assert normalize_demand(_DEMAND_SATURATION_UPM / 2) == 0.5
|
||||
|
||||
def test_at_saturation_is_one(self) -> None:
|
||||
assert normalize_demand(_DEMAND_SATURATION_UPM) == 1.0
|
||||
|
||||
def test_beyond_saturation_clamps_to_one(self) -> None:
|
||||
assert normalize_demand(_DEMAND_SATURATION_UPM * 3) == 1.0
|
||||
|
||||
def test_zero_velocity_is_zero_not_none(self) -> None:
|
||||
# 0 продаж/мес — честный ноль спроса (market_metrics уже отличил от None).
|
||||
assert normalize_demand(0.0) == 0.0
|
||||
|
||||
def test_none_passthrough(self) -> None:
|
||||
assert normalize_demand(None) is None
|
||||
|
||||
def test_zero_saturation_degrades_without_crash(self) -> None:
|
||||
assert normalize_demand(5.0, saturation_upm=0.0) == 1.0
|
||||
assert normalize_demand(0.0, saturation_upm=0.0) == 0.0
|
||||
|
||||
def test_monotonic_non_decreasing(self) -> None:
|
||||
prev = -1.0
|
||||
for upm in range(0, 80, 5):
|
||||
cur = normalize_demand(float(upm))
|
||||
assert cur is not None
|
||||
assert cur >= prev
|
||||
prev = cur
|
||||
|
||||
|
||||
# ── 1. Pure-нормализация: infra (district POI / city POI) ──────────────────────
|
||||
|
||||
|
||||
class TestNormalizeInfra:
|
||||
def test_equal_to_city_is_one(self) -> None:
|
||||
assert normalize_infra(10.0, 10.0) == 1.0
|
||||
|
||||
def test_half_of_city(self) -> None:
|
||||
assert normalize_infra(5.0, 10.0) == 0.5
|
||||
|
||||
def test_better_than_city_clamps_to_one(self) -> None:
|
||||
assert normalize_infra(25.0, 10.0) == 1.0
|
||||
|
||||
def test_district_none_is_none(self) -> None:
|
||||
# <3 ЖК с POI в районе → None (НЕ 0).
|
||||
assert normalize_infra(None, 10.0) is None
|
||||
|
||||
def test_city_none_is_none(self) -> None:
|
||||
assert normalize_infra(5.0, None) is None
|
||||
|
||||
def test_city_zero_is_none(self) -> None:
|
||||
# Деление на ноль не допускаем → None.
|
||||
assert normalize_infra(5.0, 0.0) is None
|
||||
|
||||
|
||||
# ── 2. compute_location_indices: reuse + normalize + null-on-no-data ────────────
|
||||
|
||||
|
||||
class TestComputeLocationIndices:
|
||||
def test_all_indices_populated_and_normalized(self) -> None:
|
||||
db = MagicMock()
|
||||
with (
|
||||
patch(_MM, return_value=_metrics(0.8, 25.0)),
|
||||
patch(_FSP, return_value=_fsp(0.42)),
|
||||
patch(_DPOI, return_value=12.0),
|
||||
patch(_CPOI, return_value=10.0),
|
||||
):
|
||||
idx = compute_location_indices(db, "Кировский")
|
||||
assert idx.competition_index == 0.8 # overstock_index passthrough (уже 0..1)
|
||||
assert idx.demand_index == 0.5 # 25 / 50 saturation
|
||||
assert idx.future_supply_index == 0.42 # passthrough (уже 0..1)
|
||||
assert idx.infra_index == 1.0 # 12/10 clamp to 1.0
|
||||
|
||||
def test_competition_and_demand_from_distinct_sources(self) -> None:
|
||||
# ОРТОГОНАЛЬНОСТЬ: competition ← overstock_index, demand ← unit_velocity —
|
||||
# два РАЗНЫХ поля MarketMetrics. Подаём независимые значения и проверяем, что
|
||||
# competition отражает overstock (а НЕ velocity), а demand — velocity. Так
|
||||
# фиксируем, что индексы больше не из одного sales-velocity-сигнала
|
||||
# (старый sell_through был монотонен в продажах = коррелирован с demand).
|
||||
db = MagicMock()
|
||||
with (
|
||||
# высокое затоваривание (много конкурирующего стока стоит) при НИЗКОЙ
|
||||
# скорости продаж — competition высокий, demand низкий (разъезжаются).
|
||||
patch(_MM, return_value=_metrics(0.9, 5.0)),
|
||||
patch(_FSP, return_value=_fsp(None)),
|
||||
patch(_DPOI, return_value=None),
|
||||
patch(_CPOI, return_value=10.0),
|
||||
):
|
||||
idx_high_comp = compute_location_indices(db, "Кировский")
|
||||
with (
|
||||
# мало затоваривания (сток быстро уходит) при ВЫСОКОЙ скорости —
|
||||
# competition низкий, demand высокий (инверсия первого кейса).
|
||||
patch(_MM, return_value=_metrics(0.1, 45.0)),
|
||||
patch(_FSP, return_value=_fsp(None)),
|
||||
patch(_DPOI, return_value=None),
|
||||
patch(_CPOI, return_value=10.0),
|
||||
):
|
||||
idx_low_comp = compute_location_indices(db, "Кировский")
|
||||
# competition следует за overstock, НЕ за velocity.
|
||||
assert idx_high_comp.competition_index == 0.9
|
||||
assert idx_low_comp.competition_index == 0.1
|
||||
# demand следует за velocity независимо от competition.
|
||||
assert idx_high_comp.demand_index == 0.1 # 5 / 50
|
||||
assert idx_low_comp.demand_index == 0.9 # 45 / 50
|
||||
# Главное: high-competition-кейс имеет НИЗКИЙ demand, low-competition — ВЫСОКИЙ.
|
||||
# Будь competition прежним sell_through (∝ продажам), он бы рос ВМЕСТЕ с demand;
|
||||
# здесь они расходятся → сигналы ортогональны.
|
||||
assert idx_high_comp.competition_index > idx_low_comp.competition_index
|
||||
assert idx_high_comp.demand_index < idx_low_comp.demand_index
|
||||
|
||||
def test_no_market_data_yields_none_competition_and_demand(self) -> None:
|
||||
db = MagicMock()
|
||||
with (
|
||||
patch(_MM, return_value=_metrics(None, None)),
|
||||
patch(_FSP, return_value=_fsp(None)),
|
||||
patch(_DPOI, return_value=None),
|
||||
patch(_CPOI, return_value=10.0),
|
||||
):
|
||||
idx = compute_location_indices(db, "Кировский")
|
||||
# Все None (нет данных) — НИ ОДИН не подменён 0.
|
||||
assert idx.competition_index is None
|
||||
assert idx.demand_index is None
|
||||
assert idx.future_supply_index is None
|
||||
assert idx.infra_index is None
|
||||
|
||||
def test_source_exception_isolated_to_its_index(self) -> None:
|
||||
# market_metrics бросает → competition/demand None, но future_supply + infra
|
||||
# всё равно считаются (graceful per-signal, не валит весь расчёт).
|
||||
db = MagicMock()
|
||||
with (
|
||||
patch(_MM, side_effect=RuntimeError("db boom")),
|
||||
patch(_FSP, return_value=_fsp(0.3)),
|
||||
patch(_DPOI, return_value=8.0),
|
||||
patch(_CPOI, return_value=10.0),
|
||||
):
|
||||
idx = compute_location_indices(db, "Ленинский")
|
||||
assert idx.competition_index is None
|
||||
assert idx.demand_index is None
|
||||
assert idx.future_supply_index == 0.3
|
||||
assert idx.infra_index == 0.8
|
||||
|
||||
def test_indices_within_unit_range(self) -> None:
|
||||
db = MagicMock()
|
||||
with (
|
||||
patch(_MM, return_value=_metrics(0.55, 13.0)),
|
||||
patch(_FSP, return_value=_fsp(0.7)),
|
||||
patch(_DPOI, return_value=9.0),
|
||||
patch(_CPOI, return_value=10.0),
|
||||
):
|
||||
idx = compute_location_indices(db, "Академический")
|
||||
for v in (
|
||||
idx.infra_index,
|
||||
idx.competition_index,
|
||||
idx.demand_index,
|
||||
idx.future_supply_index,
|
||||
):
|
||||
assert v is not None
|
||||
assert 0.0 <= v <= 1.0
|
||||
|
||||
|
||||
# ── 3. refresh_locations: idempotent upsert + SAVEPOINT + counters ──────────────
|
||||
|
||||
|
||||
def _refresh_db() -> MagicMock:
|
||||
"""MagicMock-сессия с рабочим begin_nested() context manager (SAVEPOINT)."""
|
||||
db = MagicMock()
|
||||
db.begin_nested.return_value.__enter__ = MagicMock()
|
||||
db.begin_nested.return_value.__exit__ = MagicMock(return_value=False)
|
||||
return db
|
||||
|
||||
|
||||
class TestRefreshLocations:
|
||||
def test_upserts_every_district(self) -> None:
|
||||
db = _refresh_db()
|
||||
districts = {"Кировский", "Ленинский", "Академический"}
|
||||
with (
|
||||
patch(_ADMIN, return_value=districts),
|
||||
patch(
|
||||
"app.services.site_finder.locations.compute_location_indices",
|
||||
return_value=SimpleNamespace(
|
||||
infra_index=0.5,
|
||||
competition_index=0.6,
|
||||
demand_index=0.4,
|
||||
future_supply_index=0.3,
|
||||
),
|
||||
),
|
||||
):
|
||||
result = refresh_locations(db)
|
||||
assert result == {"districts": 3, "upserted": 3, "failed": 0}
|
||||
# SAVEPOINT использован per-row (3 раза), один commit в конце.
|
||||
assert db.begin_nested.call_count == 3
|
||||
assert db.execute.call_count == 3
|
||||
db.commit.assert_called_once()
|
||||
|
||||
def test_idempotent_second_run_same_counts(self) -> None:
|
||||
# Идемпотентность: повторный прогон с тем же источником → те же счётчики
|
||||
# (ON CONFLICT обновляет, не плодит). На уровне сервиса это детерминизм
|
||||
# числа upsert'ов при неизменном district-источнике.
|
||||
districts = {"Кировский", "Ленинский"}
|
||||
ns = SimpleNamespace(
|
||||
infra_index=0.5, competition_index=0.6, demand_index=0.4, future_supply_index=0.3
|
||||
)
|
||||
with (
|
||||
patch(_ADMIN, return_value=districts),
|
||||
patch(
|
||||
"app.services.site_finder.locations.compute_location_indices", return_value=ns
|
||||
),
|
||||
):
|
||||
first = refresh_locations(_refresh_db())
|
||||
second = refresh_locations(_refresh_db())
|
||||
assert first == second == {"districts": 2, "upserted": 2, "failed": 0}
|
||||
|
||||
def test_failed_row_isolated_counts_as_failed(self) -> None:
|
||||
# Один район бросает на upsert → failed=1, остальные upsert'ятся (SAVEPOINT).
|
||||
db = _refresh_db()
|
||||
db.execute.side_effect = [RuntimeError("row boom"), None, None]
|
||||
districts_sorted = ["Академический", "Кировский", "Ленинский"] # sorted() в сервисе
|
||||
with (
|
||||
patch(_ADMIN, return_value=set(districts_sorted)),
|
||||
patch(
|
||||
"app.services.site_finder.locations.compute_location_indices",
|
||||
return_value=SimpleNamespace(
|
||||
infra_index=0.5,
|
||||
competition_index=0.6,
|
||||
demand_index=0.4,
|
||||
future_supply_index=0.3,
|
||||
),
|
||||
),
|
||||
):
|
||||
result = refresh_locations(db)
|
||||
assert result == {"districts": 3, "upserted": 2, "failed": 1}
|
||||
db.commit.assert_called_once()
|
||||
|
||||
def test_empty_district_source_is_noop(self) -> None:
|
||||
db = _refresh_db()
|
||||
with patch(_ADMIN, return_value=set()):
|
||||
result = refresh_locations(db)
|
||||
assert result == {"districts": 0, "upserted": 0, "failed": 0}
|
||||
db.execute.assert_not_called()
|
||||
db.commit.assert_not_called()
|
||||
109
data/sql/146_location.sql
Normal file
109
data/sql/146_location.sql
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
-- 146_location.sql
|
||||
-- #948 Part B (ТЗ §8.2): промоушн «локации» (района) в first-class сущность,
|
||||
-- несущую district-level индексы: инфра / конкуренция / спрос / будущее предложение
|
||||
-- (infra / competition / demand / future_supply).
|
||||
--
|
||||
-- ADDITIVE-подход (HARD CONSTRAINT #948B): `district` (string) НЕ переписывается в FK
|
||||
-- нигде в коде — он используется повсеместно (parcels.py analyze, весь forecasting/,
|
||||
-- #946 macro _canonical_region). Вместо рефактора заводим ОТДЕЛЬНУЮ таблицу `location`,
|
||||
-- ключуемую по `district_name` (joinable по строке). Существующий код продолжает
|
||||
-- использовать district-строки; `location` — новая queryable сущность РЯДОМ, наполняемая
|
||||
-- сервисом app/services/site_finder/locations.py (reuse per-district forecast-функций).
|
||||
--
|
||||
-- District source: 8 официальных АДМИН-районов ЕКБ из `ekb_districts`
|
||||
-- (district_name TEXT PK; 'не определён' — 9-я строка-dimension, НЕ локация).
|
||||
-- centroid: derivable из `ekb_districts_geom` (PostGIS-полигоны, миграция 56) через
|
||||
-- ST_Centroid — наполняется сервисом при refresh (тут колонка просто NULLable).
|
||||
--
|
||||
-- Индексы 0..1 normalized, NULL когда нет данных (НИКОГДА не фабрикуем 0 —
|
||||
-- graceful-on-thin-data, дух market_metrics.py / future_supply.py). indices_computed_at
|
||||
-- — когда сервис последний раз пересчитывал индексы (NULL до первого refresh).
|
||||
--
|
||||
-- Read-only сущность: индексы ВЫЧИСЛЯЮТСЯ воркером (tasks/location_refresh.py), НЕ
|
||||
-- вводятся пользователем — поэтому CRUD нет, только GET-API (app/api/v1/locations.py).
|
||||
-- Access: backend rbac_guard хард-блокирует ТОЛЬКО /api/v1/admin/*; на /api/v1/locations
|
||||
-- pilot отсекается FRONTEND-овым RouteGuard + Caddy (как insights/custom-pois),
|
||||
-- до эндпоинта доходят analyst+admin.
|
||||
--
|
||||
-- Deploy: auto-applied deploy.yml через _schema_migrations (ровно один раз NN=146).
|
||||
-- Idempotent: CREATE TABLE/INDEX IF NOT EXISTS + guarded CHECK.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ── 1. Таблица location ─────────────────────────────────────────────────────────
|
||||
-- district_name UNIQUE NOT NULL — логический ключ join'а к district-строкам (insight,
|
||||
-- objective_lots, ekb_districts, …). id bigserial PK — стабильный суррогат для будущих
|
||||
-- FK (additive: текущий код по нему НЕ ходит). region NULL — пока все локации ЕКБ
|
||||
-- (Свердл. обл.), колонка на будущее мульти-регион. centroid — derivable из
|
||||
-- ekb_districts_geom, наполняется сервисом (ST_Centroid) при refresh.
|
||||
CREATE TABLE IF NOT EXISTS location (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
district_name TEXT NOT NULL, -- логический ключ join'а
|
||||
region TEXT, -- NULL = ЕКБ/Свердл (на будущее)
|
||||
centroid GEOMETRY(POINT, 4326), -- derivable из ekb_districts_geom
|
||||
-- ── §8.2 district-level индексы (0..1 normalized; NULL = нет данных) ──────────
|
||||
infra_index NUMERIC, -- POI района / POI по городу
|
||||
competition_index NUMERIC, -- market_metrics.overstock_index (доступное конкур. предложение)
|
||||
demand_index NUMERIC, -- market_metrics.unit_velocity (saturated)
|
||||
future_supply_index NUMERIC, -- future_supply.index (уже 0..1)
|
||||
indices_computed_at TIMESTAMPTZ, -- когда сервис посл. пересчитал
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ── 2. UNIQUE на district_name (логический ключ + цель ON CONFLICT в сервисе) ─────
|
||||
-- Через UNIQUE INDEX (не inline-констрейнт) — idempotent CREATE UNIQUE INDEX IF NOT
|
||||
-- EXISTS, и одновременно обслуживает point-lookup GET /locations/{district_name}.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_location_district_name
|
||||
ON location (district_name);
|
||||
|
||||
-- ── 3. CHECK: индексы в диапазоне [0,1] (guarded — idempotent при ре-apply) ───────
|
||||
-- NULL допустим (нет данных). Защищает от случайной записи ненормированного значения
|
||||
-- (дисциплина «0..1 или NULL»). Расширять/менять — новой миграцией (DROP IF EXISTS + ADD).
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'ck_location_indices_range'
|
||||
) THEN
|
||||
ALTER TABLE location
|
||||
ADD CONSTRAINT ck_location_indices_range
|
||||
CHECK (
|
||||
(infra_index IS NULL OR infra_index BETWEEN 0 AND 1)
|
||||
AND (competition_index IS NULL OR competition_index BETWEEN 0 AND 1)
|
||||
AND (demand_index IS NULL OR demand_index BETWEEN 0 AND 1)
|
||||
AND (future_supply_index IS NULL OR future_supply_index BETWEEN 0 AND 1)
|
||||
);
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- ── 4. Индекс: spatial-lookup по centroid (как ekb_districts_geom #56) ────────────
|
||||
CREATE INDEX IF NOT EXISTS idx_location_centroid_gist
|
||||
ON location USING GIST (centroid);
|
||||
|
||||
COMMENT ON TABLE location IS
|
||||
'First-class «локация» (район) с district-level индексами инфра/конкуренция/спрос/'
|
||||
'будущее предложение (#948 Part B, ТЗ §8.2). ADDITIVE: ключ district_name joinable '
|
||||
'по строке, district НЕ рефакторится в FK. Индексы вычисляются воркером '
|
||||
'(tasks/location_refresh.py через services/site_finder/locations.py), read-only API.';
|
||||
COMMENT ON COLUMN location.district_name IS
|
||||
'Логический ключ join''а к district-строкам (insight/objective_lots/ekb_districts). '
|
||||
'UNIQUE NOT NULL. 8 админ-районов ЕКБ (без ''не определён'').';
|
||||
COMMENT ON COLUMN location.infra_index IS
|
||||
'Инфра-индекс 0..1: POI района / POI по городу (analytics_queries._district_poi_score / '
|
||||
'_city_avg_poi_score), clamp [0,1]. NULL если в районе <3 ЖК с POI.';
|
||||
COMMENT ON COLUMN location.competition_index IS
|
||||
'Индекс конкуренции 0..1: market_metrics.overstock_index (доля долго-непроданного '
|
||||
'конкурирующего стока = доступное/затоваренное конкурирующее предложение; выше = '
|
||||
'больше конкурентного давления для нового игрока). Ортогонален demand (скорости '
|
||||
'продаж). NULL при недостатке данных.';
|
||||
COMMENT ON COLUMN location.demand_index IS
|
||||
'Индекс спроса 0..1: насыщающее преобразование market_metrics.unit_velocity (ед./мес). '
|
||||
'NULL при недостатке данных.';
|
||||
COMMENT ON COLUMN location.future_supply_index IS
|
||||
'Индекс будущего предложения 0..1: future_supply.compute_future_supply_pressure(...).index '
|
||||
'(уже нормирован 0..1). NULL при недостатке supply-данных.';
|
||||
COMMENT ON COLUMN location.indices_computed_at IS
|
||||
'Когда сервис последний раз пересчитал индексы (NULL до первого refresh).';
|
||||
|
||||
COMMIT;
|
||||
Loading…
Add table
Reference in a new issue