-- 148_own_planned_project.sql -- #1169 PR 1 (Foundation, ТЗ §25.3): manual-entry «наш будущий/планируемый проект» — -- девелопер сам заводит проект из закрытого пайплайна (ещё НЕ в DOM.РФ), чтобы движок -- каннибализации §25.3 (PR 2) мог сравнивать кандидата с НАШИМ портфелем по 4 осям: -- аудитория/класс, тайминг (месяц выхода), цена (₽/м²), квартирография (unit_mix). -- -- Парный источник к «текущим» проектам: те читаются из domrf_kn_objects по -- settings.own_developer_ids (см. app/services/site_finder/own_portfolio.py). Эта -- таблица — ВТОРОЙ источник («будущие», приватный пайплайн), оба нормализуются в -- общий OwnProject. created_by фиксирует автора (из X-Authenticated-User), как insight. -- -- Access control: backend rbac_guard (app/main.py) хард-блокирует ТОЛЬКО /api/v1/admin/*. -- На /api/v1/own-projects pilot отсекается FRONTEND-овым RouteGuard + Caddy, НЕ бэкендом; -- до эндпоинта доходят analyst+admin (зеркало insight #948). -- -- Соглашение по осям (зеркало forecasting SegmentSpec / sales_series.py): obj_class — -- «человеческий» класс (Стандарт/Комфорт/Бизнес/Премиум — мягкий enum, NULL допустим; -- матчинг в PR2 регистронезависимый). planned_release_month — ПЕРВОЕ число месяца -- (YYYY-MM-01), как report_month рядов продаж. price_*_per_m2 — ₽/м² (единый масштаб с -- objective price_per_m2_rub). unit_mix — доли квартирографии {"studio":0.3,"1k":0.4,...} -- (каждая доля в [0,1]; сумма ≈ 1 — мягко, не констрейнтим точную сумму, т.к. возможны -- округления/нежилое). -- -- Deploy: auto-applied deploy.yml через _schema_migrations (ровно один раз NN=148). -- Idempotent: CREATE TABLE/INDEX IF NOT EXISTS + guarded CHECK (DO-блоки). BEGIN; -- ── 1. Таблица own_planned_project ──────────────────────────────────────────── CREATE TABLE IF NOT EXISTS own_planned_project ( id BIGSERIAL PRIMARY KEY, created_by TEXT NOT NULL, -- автор, из X-Authenticated-User name TEXT NOT NULL, -- название проекта/ЖК district TEXT, -- район (location ref, как insight) obj_class TEXT, -- Стандарт|Комфорт|Бизнес|Премиум -- Тайминг: планируемый месяц выхода в продажу. Хранится как ПЕРВОЕ число месяца -- (YYYY-MM-01) — нормализуется на уровне сервиса/SQL; ось тайминга §25.3. planned_release_month DATE, -- Цена ₽/м² (вилка). min/max независимы; оба ≥0 (guarded CHECK ниже). price_min_per_m2 NUMERIC, price_max_per_m2 NUMERIC, -- Квартирография: доли по форматам {"studio":0.3,"1k":0.4,"2k":0.2,"3k":0.1}. -- JSONB-объект; каждое значение в [0,1] (guarded CHECK ниже). Сумму ≈1 НЕ -- констрейнтим жёстко (округления / нежилая часть). unit_mix JSONB, lon DOUBLE PRECISION CHECK (lon IS NULL OR lon BETWEEN -180 AND 180), lat DOUBLE PRECISION CHECK (lat IS NULL OR lat BETWEEN -90 AND 90), -- Опциональный центроид проекта: GENERATED из lon/lat (как insight.geom #948). -- NULL если координаты не заданы (обе должны быть заданы вместе). geom GEOMETRY(POINT, 4326) GENERATED ALWAYS AS ( CASE WHEN lon IS NOT NULL AND lat IS NOT NULL THEN ST_SetSRID(ST_MakePoint(lon, lat), 4326) END ) STORED, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); COMMENT ON TABLE own_planned_project IS 'Manual-entry «наш будущий/планируемый проект» приватного пайплайна (#1169 PR1, ' 'ТЗ §25.3). Источник «будущих» проектов для движка каннибализации §25.3 (PR2) — ' 'парный к «текущим» из domrf_kn_objects по settings.own_developer_ids. created_by = ' 'автор из X-Authenticated-User. Нормализуется в OwnProject (own_portfolio.py).'; COMMENT ON COLUMN own_planned_project.obj_class IS 'Класс проекта (мягкий enum: Стандарт|Комфорт|Бизнес|Премиум; NULL допустим). ' 'Зеркалит class-вокабуляр forecasting SegmentSpec; матчинг в PR2 регистронезависимый.'; COMMENT ON COLUMN own_planned_project.planned_release_month IS 'Планируемый месяц выхода в продажу — ПЕРВОЕ число месяца (YYYY-MM-01). Ось тайминга §25.3.'; COMMENT ON COLUMN own_planned_project.price_min_per_m2 IS 'Нижняя граница цены, ₽/м² (≥0).'; COMMENT ON COLUMN own_planned_project.price_max_per_m2 IS 'Верхняя граница цены, ₽/м² (≥0).'; COMMENT ON COLUMN own_planned_project.unit_mix IS 'Квартирография: JSONB долей по форматам {"studio":0.3,"1k":0.4,...}; каждая доля в [0,1].'; COMMENT ON COLUMN own_planned_project.geom IS 'Опциональный центроид, GENERATED из lon/lat. NULL если координаты не заданы.'; -- ── 2. CHECK на цены ≥0 (guarded — idempotent при ре-apply) ─────────────────── DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'ck_own_planned_project_price_nonneg' ) THEN ALTER TABLE own_planned_project ADD CONSTRAINT ck_own_planned_project_price_nonneg CHECK ( (price_min_per_m2 IS NULL OR price_min_per_m2 >= 0) AND (price_max_per_m2 IS NULL OR price_max_per_m2 >= 0) ); END IF; END $$; -- ── 3. CHECK на unit_mix: объект, все значения числовые в [0,1] (guarded) ────── -- Разрешаем NULL (не задано). Если задан — обязан быть JSON-объектом, и каждое -- значение должно быть числом в [0,1] (доля). -- -- ВАЖНО: PostgreSQL ЗАПРЕЩАЕТ подзапросы в CHECK-констрейнтах («cannot use subquery -- in check constraint»), а перебор произвольных ключей JSONB требует jsonb_each → -- подзапрос. Поэтому валидацию выносим в IMMUTABLE-функцию и зовём её из CHECK -- (канонический паттерн для JSONB-валидации в констрейнте). Функция чистая -- (детерминирована от входа) → IMMUTABLE безопасно. CREATE OR REPLACE FUNCTION own_planned_project_unit_mix_valid(mix jsonb) RETURNS boolean LANGUAGE sql IMMUTABLE AS $$ SELECT mix IS NULL OR ( jsonb_typeof(mix) = 'object' AND NOT EXISTS ( SELECT 1 FROM jsonb_each(mix) AS kv(k, v) WHERE jsonb_typeof(v) <> 'number' OR (v)::numeric < 0 OR (v)::numeric > 1 ) ); $$; COMMENT ON FUNCTION own_planned_project_unit_mix_valid(jsonb) IS 'TRUE если unit_mix = NULL ИЛИ JSON-объект, все значения которого — числа в [0,1] ' '(доли квартирографии). Вынесено из CHECK, т.к. подзапросы в CHECK запрещены (#1169).'; DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'ck_own_planned_project_unit_mix_shares' ) THEN ALTER TABLE own_planned_project ADD CONSTRAINT ck_own_planned_project_unit_mix_shares CHECK (own_planned_project_unit_mix_valid(unit_mix)); END IF; END $$; -- ── 4. Индексы: фильтры list-эндпоинта + §25.3 чтение ───────────────────────── CREATE INDEX IF NOT EXISTS idx_own_planned_project_district ON own_planned_project (district); CREATE INDEX IF NOT EXISTS idx_own_planned_project_obj_class ON own_planned_project (obj_class); CREATE INDEX IF NOT EXISTS idx_own_planned_project_created_by ON own_planned_project (created_by); CREATE INDEX IF NOT EXISTS idx_own_planned_project_release_month ON own_planned_project (planned_release_month); -- Опциональный geo-lookup (как insight geom GIST #948). CREATE INDEX IF NOT EXISTS idx_own_planned_project_geom_gist ON own_planned_project USING GIST (geom); COMMIT;