gendesign/data/sql/148_own_planned_project.sql
Light1YT df34e55ab4
All checks were successful
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) Successful in 6m25s
CI / backend-tests (pull_request) Successful in 6m22s
Deploy / build-frontend (push) Has been skipped
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Successful in 1m31s
Deploy / build-worker (push) Successful in 2m39s
Deploy / deploy (push) Successful in 1m12s
feat(site-finder): own-portfolio data source for §25.3 cannibalization (#1169 PR1)
Foundation PR: unified "our projects" source the §25.3 overlap engine (PR2)
will consume. Two origins normalized to OwnProject (class/timing/price/unit-mix):
- current <- domrf_kn_objects filtered by settings.own_developer_ids (numeric
  prefix of composite dev_id; empty -> [] graceful, no DB hit, no hardcoded id)
- future  <- new manual-entry own_planned_project entity (migration 148)

Adds OWN_DEVELOPER_IDS config (comma-sep -> list[int], default []),
own_planned_project table (range/unit_mix CHECKs via IMMUTABLE helper, generated
geom), /api/v1/own-projects CRUD (created_by from X-Authenticated-User), and
get_own_portfolio(db). Per-source graceful degradation; psycopg-v3 CAST clean.

Does not touch special_indices.py or parcels.py (out of scope).

Refs #1169
2026-06-08 16:21:53 +05:00

149 lines
9.6 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.

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