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).
109 lines
8.2 KiB
PL/PgSQL
109 lines
8.2 KiB
PL/PgSQL
-- 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;
|