gendesign/data/sql/148_own_planned_project.sql
Light1YT 86e9ea2937 fix(week-review): автофиксы код-ревью — 169 issue (label «week ревью 1»)
Многоагентный аудит + имплементация: один воркер на файл, точечные правки.
Верификация: py_compile (47/47 .py) + tsc --noEmit (0 ошибок). Unit-тесты
не прогонялись (окружение не поднято: rollup native dep / нет pytest-venv).

Полностью исправлено (169): #1336, #1337, #1339, #1340, #1341, #1342, #1343, #1345, #1346, #1348, #1349, #1350, #1351, #1354, #1356, #1358, #1359, #1360, #1362, #1364, #1365, #1366, #1367, #1368, #1369, #1370, #1371, #1372, #1373, #1374, #1375, #1376, #1377, #1378, #1379, #1380, #1381, #1382, #1384, #1385, #1386, #1387, #1388, #1389, #1390, #1391, #1392, #1394, #1395, #1396, #1397, #1399, #1400, #1401, #1402, #1403, #1404, #1408, #1409, #1410, #1411, #1412, #1413, #1414, #1415, #1416, #1417, #1418, #1420, #1423, #1425, #1426, #1427, #1428, #1429, #1430, #1431, #1432, #1433, #1434, #1435, #1437, #1438, #1439, #1440, #1441, #1442, #1443, #1444, #1445, #1446, #1447, #1448, #1449, #1450, #1451, #1452, #1453, #1454, #1455, #1456, #1457, #1458, #1459, #1460, #1461, #1462, #1463, #1464, #1465, #1466, #1467, #1468, #1469, #1471, #1472, #1473, #1474, #1476, #1478, #1479, #1481, #1482, #1483, #1484, #1485, #1487, #1488, #1489, #1490, #1491, #1492, #1493, #1494, #1495, #1496, #1497, #1499, #1500, #1501, #1502, #1504, #1505, #1506, #1507, #1510, #1514, #1515, #1516, #1517, #1518, #1519, #1521, #1522, #1523, #1524, #1525, #1526, #1527, #1528, #1529, #1531, #1532, #1533, #1534, #1535, #1536, #1537, #1538

Частично (9, in-file часть, остаток cross-file): #1361, #1419, #1422, #1424, #1470, #1475, #1477, #1480, #1498
Требуют cross-file (3, не тронуты): #1338, #1363, #1421
Пропущено (1): #1539

Не входило в партию: 22 needs-Leha issue (нужны решения владельца).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 20:21:11 +05:00

172 lines
11 KiB
PL/PgSQL
Raw Permalink 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,
-- Цена ₽/м² (вилка). Оба ≥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;