Compare commits
2 commits
main
...
feat/locat
| Author | SHA1 | Date | |
|---|---|---|---|
| e5835b25b7 | |||
| 84a4aa5782 |
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,
|
custom_pois,
|
||||||
insights,
|
insights,
|
||||||
landing,
|
landing,
|
||||||
|
locations,
|
||||||
me,
|
me,
|
||||||
parcels,
|
parcels,
|
||||||
photos,
|
photos,
|
||||||
|
|
@ -169,6 +170,7 @@ app.include_router(
|
||||||
)
|
)
|
||||||
app.include_router(photos.router, prefix="/api/v1/photos", tags=["photos"])
|
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(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(insights.router, prefix="/api/v1/insights", tags=["insights"])
|
||||||
app.include_router(
|
app.include_router(
|
||||||
admin_cadastre.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"},
|
"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
|
return schedule
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ celery_app = Celery(
|
||||||
"app.workers.tasks.cbr_macro_sync",
|
"app.workers.tasks.cbr_macro_sync",
|
||||||
"app.workers.tasks.rosstat_macro_sync",
|
"app.workers.tasks.rosstat_macro_sync",
|
||||||
"app.workers.tasks.supply_layers_refresh",
|
"app.workers.tasks.supply_layers_refresh",
|
||||||
|
"app.workers.tasks.location_refresh",
|
||||||
"app.workers.tasks.forecast",
|
"app.workers.tasks.forecast",
|
||||||
"app.workers.tasks.ird_harvest",
|
"app.workers.tasks.ird_harvest",
|
||||||
"app.workers.tasks.ekb_krt_sync",
|
"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