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