gendesign/data/sql/146_location.sql
Light1YT 8da1c00138
All checks were successful
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
Deploy / changes (push) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
Deploy / build-backend (push) Successful in 1m29s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m42s
Deploy / deploy (push) Successful in 1m11s
CI / backend-tests (push) Successful in 6m27s
CI / backend-tests (pull_request) Successful in 6m22s
feat(location): district-level Location entity + indices (#948 part B)
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).
2026-06-08 13:28:19 +05:00

109 lines
8.2 KiB
PL/PgSQL
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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