Многоагентный аудит + имплементация: один воркер на файл, точечные правки. Верификация: 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>
172 lines
11 KiB
PL/PgSQL
172 lines
11 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,
|
||
-- Цена ₽/м² (вилка). Оба ≥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;
|