diff --git a/backend/app/api/v1/locations.py b/backend/app/api/v1/locations.py new file mode 100644 index 00000000..2a5c9ccc --- /dev/null +++ b/backend/app/api/v1/locations.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py index 7ffdddf5..8c6c6947 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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", diff --git a/backend/app/schemas/location.py b/backend/app/schemas/location.py new file mode 100644 index 00000000..91cd2414 --- /dev/null +++ b/backend/app/schemas/location.py @@ -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] diff --git a/backend/app/services/site_finder/locations.py b/backend/app/services/site_finder/locations.py new file mode 100644 index 00000000..31ed6e8d --- /dev/null +++ b/backend/app/services/site_finder/locations.py @@ -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) diff --git a/backend/app/workers/beat_schedule.py b/backend/app/workers/beat_schedule.py index 6958f9ce..5a041cb1 100644 --- a/backend/app/workers/beat_schedule.py +++ b/backend/app/workers/beat_schedule.py @@ -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 diff --git a/backend/app/workers/celery_app.py b/backend/app/workers/celery_app.py index 1d570e0a..b6374f39 100644 --- a/backend/app/workers/celery_app.py +++ b/backend/app/workers/celery_app.py @@ -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", diff --git a/backend/app/workers/tasks/location_refresh.py b/backend/app/workers/tasks/location_refresh.py new file mode 100644 index 00000000..24c84b1b --- /dev/null +++ b/backend/app/workers/tasks/location_refresh.py @@ -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() diff --git a/backend/tests/api/v1/test_locations.py b/backend/tests/api/v1/test_locations.py new file mode 100644 index 00000000..10c98b4b --- /dev/null +++ b/backend/tests/api/v1/test_locations.py @@ -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 diff --git a/backend/tests/services/site_finder/test_locations.py b/backend/tests/services/site_finder/test_locations.py new file mode 100644 index 00000000..4edeb5e6 --- /dev/null +++ b/backend/tests/services/site_finder/test_locations.py @@ -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() diff --git a/data/sql/146_location.sql b/data/sql/146_location.sql new file mode 100644 index 00000000..66118c8e --- /dev/null +++ b/data/sql/146_location.sql @@ -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;