-- 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,
    -- Цена ₽/м² (вилка). Оба ≥0; min ≤ max когда обе границы заданы (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; ≤ price_max_per_m2 когда обе заданы).';
COMMENT ON COLUMN own_planned_project.price_max_per_m2 IS 'Верхняя граница цены, ₽/м² (≥0; ≥ price_min_per_m2 когда обе заданы).';
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
$$;

-- ── 2b. CHECK на корректность вилки: min ≤ max (guarded — idempotent) ──────────
-- DB-уровневый backstop инварианта price-range (#1362). Pydantic-валидатор есть
-- только на OwnPlannedProjectCreate; PUT (OwnPlannedProjectUpdate) и partial-merge
-- в service-слое могут записать инвертированную вилку (min > max), которая молча
-- кормит §25.3 cannibalization бессмысленными ценовыми полосами. Этот CHECK ловит
-- инверсию независимо от пути записи (create / partial update / прямой SQL).
-- Срабатывает ТОЛЬКО когда обе границы заданы — частичная вилка (одна NULL) валидна.
DO $$
BEGIN
    IF NOT EXISTS (
        SELECT 1 FROM pg_constraint WHERE conname = 'ck_own_planned_project_price_range'
    ) THEN
        ALTER TABLE own_planned_project
            ADD CONSTRAINT ck_own_planned_project_price_range
            CHECK (
                price_min_per_m2 IS NULL
                OR price_max_per_m2 IS NULL
                OR price_min_per_m2 <= price_max_per_m2
            );
    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;
