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