feat(location): district-level Location entity + indices (#948 part B)
Some checks failed
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Has been cancelled
CI / backend-tests (pull_request) Successful in 6m23s

Promote district to a first-class `location` entity (ТЗ §8.2), ADDITIVE — no
district->FK refactor. New `location` table keyed by district_name (joinable by
string), carrying 4 normalized [0,1] indices (NULL when no data, never 0):
- infra: _district_poi_score / _city_avg_poi_score (per-district POI aggregate)
- competition: market_metrics.overstock_index (available/stuck competing supply
  — orthogonal to demand; NOT sell-through, which is market-heat correlated w/ demand)
- demand: market_metrics.unit_velocity (saturating /50)
- future_supply: future_supply_pressure.index (passthrough, already 0..1)

- data/sql/146_location.sql: idempotent table + UNIQUE(district_name) + range
  CHECK + centroid GIST
- services/site_finder/locations.py: compute_location_indices (reuses forecast
  per-district fns) + refresh_locations (SAVEPOINT per-row, CAST, ON CONFLICT)
- workers/tasks/location_refresh.py + beat (Mon 07:00 MSK, after supply-layers)
- api/v1/locations.py: read-only GET list + GET by name (analyst+admin via rbac,
  frontend pilot-gated)
- tests: 34 (normalization 0..1/null, competition⊥demand orthogonality, idempotent
  upsert, read API list/by-name/404)

Part of #948 (Part A insight shipped #1164).
This commit is contained in:
Light1YT 2026-06-08 13:17:40 +05:00
parent 86828d0388
commit 84a4aa5782
10 changed files with 1236 additions and 0 deletions

View 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

View file

@ -28,6 +28,7 @@ from app.api.v1 import (
concepts,
custom_pois,
landing,
locations,
me,
parcels,
photos,
@ -168,6 +169,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(
admin_cadastre.router,
prefix="/api/v1/admin/cadastre",

View 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]

View 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)

View file

@ -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

View file

@ -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",

View 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()

View 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

View 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
View 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;