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
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
149 lines
9.6 KiB
PL/PgSQL
149 lines
9.6 KiB
PL/PgSQL
-- 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;
|