Per code review audit (May 14): файл без transaction wrapper — backfill CTE INSERTs + view recreations executed как individual auto-commit statements. Mid-file crash → orphan rows в complexes/complex_sources с no rollback path. ## Fix `BEGIN;` после header comments (line 31, перед section 1). `COMMIT;` в конце файла после последнего COMMENT ON VIEW. Wraps все 6 секций atomically: 1-2: CREATE TABLE + CREATE INDEX для complexes + complex_sources 3-5: 3 backfill CTE INSERTs с ON CONFLICT DO NOTHING 6: DROP VIEW IF EXISTS + CREATE VIEW + COMMENT для v_complex_full ## No CONCURRENTLY split needed Проверил — все 10 indexes используют plain `CREATE INDEX IF NOT EXISTS` без CONCURRENTLY keyword. Безопасно обернуть в одну tx. ## Idempotency preserved Все existing guards остаются: - CREATE TABLE/INDEX IF NOT EXISTS - ON CONFLICT (source, source_id) DO NOTHING - DROP VIEW IF EXISTS Re-running file двукратно — safe. ## Vault `fixes/Migration_73_Complexes_Atomic_May14.md` — created (status: resolved). Co-authored-by: lekss361 <claudestars@proton.me>
322 lines
15 KiB
PL/PgSQL
322 lines
15 KiB
PL/PgSQL
-- ЖК как canonical master entity + cross-ref на источники.
|
||
--
|
||
-- Зачем:
|
||
-- До этого жил мир из 5+ таблиц с дублирующейся семантикой "ЖК":
|
||
-- - domrf_kn_objects (4548, ДОМ.РФ kn-API)
|
||
-- - objective_lots.project_name (350 уникальных, Objective API)
|
||
-- - objective_corpus_room_month.project_name (тот же, 350)
|
||
-- - objective_complex_mapping (114 fuzzy matches → domrf_obj_id)
|
||
-- - cad_buildings.cad_num (косвенно через NSPD)
|
||
-- - rosreestr_deals.complex_name (текстовое поле в сделках)
|
||
-- Каждый источник называет ЖК по-своему ("ЖК \"Парк Культуры\"" vs
|
||
-- "Парк Культуры" vs domrf_obj_id=66845). recommend_mix /
|
||
-- velocity-anomaly / cross-source analytics требуют одного entity.
|
||
--
|
||
-- Архитектура:
|
||
-- complexes (master) — одна строка = один реальный ЖК
|
||
-- complex_sources (cross-ref) — many-to-many ЖК ↔ источник + source_id
|
||
--
|
||
-- Стратегия backfill:
|
||
-- 1. Каждый domrf_kn_objects = entity в complexes (4548 ЖК)
|
||
-- + complex_sources(source='domrf_kn', source_id=obj_id)
|
||
-- 2. Сматченные objective project_name (114) → существующий complex по
|
||
-- objective_complex_mapping.domrf_obj_id
|
||
-- + complex_sources(source='objective', source_id=project_name)
|
||
-- 3. Unmatched objective project_name (236) → новые entity в complexes
|
||
-- + complex_sources(source='objective')
|
||
--
|
||
-- Идемпотентно через ON CONFLICT.
|
||
|
||
BEGIN;
|
||
|
||
-- ── 1. complexes (canonical master) ─────────────────────────────────────────
|
||
|
||
CREATE TABLE IF NOT EXISTS complexes (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
-- Display
|
||
canonical_name TEXT NOT NULL, -- "Парк Культуры" / "ЖК Авангард 3.0"
|
||
developer_name TEXT, -- "PRINZIP" / "ГК КОРТРОС"
|
||
developer_id TEXT, -- FK on domrf_developers.developer_id (nullable)
|
||
-- Geo
|
||
region_id INTEGER, -- 66 для Свердл.обл (FK on domrf_regions.region_id)
|
||
district_name TEXT, -- "Академический" / "Автовокзал"
|
||
address TEXT,
|
||
latitude NUMERIC,
|
||
longitude NUMERIC,
|
||
geom geometry(Point, 4326), -- spatial для ST_DWithin / spatial join
|
||
-- Spec
|
||
obj_class TEXT, -- "комфорт" / "бизнес" / "стандарт" / "элитный"
|
||
flat_count INTEGER, -- общее ПД
|
||
square_living NUMERIC, -- общая жилая площадь
|
||
floor_min INTEGER,
|
||
floor_max INTEGER,
|
||
wall_type TEXT, -- "монолит" / "кирпич" / etc.
|
||
-- Stage
|
||
sales_start_date DATE,
|
||
plan_completion_date DATE,
|
||
actual_completion_date DATE,
|
||
site_status TEXT, -- "строящийся" / "сдан" / "архив"
|
||
obj_status INTEGER, -- 0/1/2 кодировка ДОМ.РФ
|
||
is_problem BOOLEAN DEFAULT FALSE,
|
||
has_escrow BOOLEAN, -- эскроу-проект
|
||
-- Audit
|
||
primary_source TEXT NOT NULL DEFAULT 'domrf_kn', -- какой источник был основой
|
||
created_at timestamptz NOT NULL DEFAULT NOW(),
|
||
updated_at timestamptz NOT NULL DEFAULT NOW()
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS complexes_name_idx
|
||
ON complexes(canonical_name);
|
||
CREATE INDEX IF NOT EXISTS complexes_developer_idx
|
||
ON complexes(developer_id) WHERE developer_id IS NOT NULL;
|
||
CREATE INDEX IF NOT EXISTS complexes_region_district_idx
|
||
ON complexes(region_id, district_name);
|
||
CREATE INDEX IF NOT EXISTS complexes_class_idx
|
||
ON complexes(obj_class) WHERE obj_class IS NOT NULL;
|
||
CREATE INDEX IF NOT EXISTS complexes_geom_idx
|
||
ON complexes USING GIST (geom) WHERE geom IS NOT NULL;
|
||
CREATE INDEX IF NOT EXISTS complexes_status_idx
|
||
ON complexes(site_status) WHERE site_status IS NOT NULL;
|
||
|
||
COMMENT ON TABLE complexes IS
|
||
'Canonical master entity для ЖК. Один реальный жилой комплекс = одна строка. '
|
||
'Связи на разные источники (domrf_kn, objective, rosreestr, etc.) — через '
|
||
'complex_sources. Все analytics-запросы должны идти через эту таблицу '
|
||
'вместо direct lookup в источниках.';
|
||
|
||
-- ── 2. complex_sources (cross-ref) ──────────────────────────────────────────
|
||
|
||
CREATE TABLE IF NOT EXISTS complex_sources (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
complex_id BIGINT NOT NULL REFERENCES complexes(id) ON DELETE CASCADE,
|
||
source TEXT NOT NULL, -- 'domrf_kn' | 'objective' | 'rosreestr' | 'cad_buildings' | 'yandex' | ...
|
||
source_id TEXT NOT NULL, -- obj_id::text / project_name / cad_num / external_id
|
||
source_external_id BIGINT, -- для int-ID источников (objective.project_id, domrf.obj_id)
|
||
-- Match audit
|
||
match_method TEXT, -- 'exact_name' | 'fuzzy_name' | 'spatial' | 'manual' | 'imported'
|
||
match_score NUMERIC, -- 0..1 для fuzzy
|
||
is_reviewed BOOLEAN NOT NULL DEFAULT FALSE,
|
||
note TEXT,
|
||
linked_at timestamptz NOT NULL DEFAULT NOW(),
|
||
UNIQUE (source, source_id) -- один (источник, source_id) — на один ЖК
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS complex_sources_complex_idx
|
||
ON complex_sources(complex_id);
|
||
CREATE INDEX IF NOT EXISTS complex_sources_source_idx
|
||
ON complex_sources(source);
|
||
CREATE INDEX IF NOT EXISTS complex_sources_external_id_idx
|
||
ON complex_sources(source, source_external_id) WHERE source_external_id IS NOT NULL;
|
||
CREATE INDEX IF NOT EXISTS complex_sources_unreviewed_idx
|
||
ON complex_sources(is_reviewed) WHERE is_reviewed = FALSE;
|
||
|
||
COMMENT ON TABLE complex_sources IS
|
||
'Cross-ref many-to-many: ЖК (complexes) ↔ его представления в источниках. '
|
||
'Один реальный ЖК может иметь записи в нескольких источниках — здесь '
|
||
'хранятся все эти ссылки. UNIQUE(source, source_id) гарантирует что один '
|
||
'(источник, ID-в-источнике) присвоен ровно одному ЖК.';
|
||
|
||
-- ── 3. Backfill из domrf_kn_objects ─────────────────────────────────────────
|
||
|
||
-- ВАЖНО: domrf_kn_objects содержит несколько snapshot'ов на один obj_id
|
||
-- (4548 rows = 1516 уникальных obj_id × ~3 snapshot_date). Поэтому
|
||
-- DISTINCT ON (obj_id) ORDER BY snapshot_date DESC — берём свежий snapshot.
|
||
--
|
||
-- ВНИМАНИЕ: domrf_kn_objects.obj_class = NULL во всех строках (известный
|
||
-- баг kn-API), поэтому obj_class останется NULL — он подтянется когда
|
||
-- сделаем UPDATE из objective_lots (там obj_class заполнен).
|
||
|
||
WITH latest_kn AS (
|
||
SELECT DISTINCT ON (obj_id)
|
||
obj_id, comm_name, addr, region_cd, dev_id, dev_name, district_name,
|
||
latitude, longitude, obj_class, flat_count, square_living,
|
||
floor_min, floor_max, wall_type, ready_dt,
|
||
site_status, obj_status, is_problem, escrow
|
||
FROM domrf_kn_objects
|
||
ORDER BY obj_id, snapshot_date DESC NULLS LAST
|
||
),
|
||
inserted_complexes AS (
|
||
INSERT INTO complexes (
|
||
canonical_name, developer_name, developer_id, region_id, district_name,
|
||
address, latitude, longitude, geom,
|
||
obj_class, flat_count, square_living, floor_min, floor_max, wall_type,
|
||
plan_completion_date, site_status, obj_status, is_problem, has_escrow,
|
||
primary_source
|
||
)
|
||
SELECT
|
||
COALESCE(comm_name, addr, 'unknown'),
|
||
dev_name, dev_id, region_cd, district_name,
|
||
addr, latitude, longitude,
|
||
CASE WHEN latitude IS NOT NULL AND longitude IS NOT NULL
|
||
THEN ST_SetSRID(ST_MakePoint(longitude, latitude), 4326) ELSE NULL END,
|
||
obj_class, flat_count, square_living, floor_min, floor_max, wall_type,
|
||
ready_dt, site_status, obj_status, COALESCE(is_problem, FALSE), escrow,
|
||
'domrf_kn'
|
||
FROM latest_kn
|
||
RETURNING id, canonical_name
|
||
)
|
||
INSERT INTO complex_sources (
|
||
complex_id, source, source_id, source_external_id,
|
||
match_method, match_score, is_reviewed, note
|
||
)
|
||
SELECT
|
||
ic.id, 'domrf_kn', lk.obj_id::text, lk.obj_id, 'imported', 1.0, TRUE,
|
||
'auto-import from domrf_kn_objects (latest snapshot per obj_id)'
|
||
FROM latest_kn lk
|
||
JOIN inserted_complexes ic
|
||
ON ic.canonical_name = COALESCE(lk.comm_name, lk.addr, 'unknown')
|
||
ON CONFLICT (source, source_id) DO NOTHING;
|
||
|
||
-- ── 4. Backfill из objective_complex_mapping (114 сматченных) ───────────────
|
||
|
||
-- Эти project_name уже привязаны к domrf_obj_id — линкуем к существующему complex.
|
||
INSERT INTO complex_sources (
|
||
complex_id, source, source_id, source_external_id,
|
||
match_method, match_score, is_reviewed, note
|
||
)
|
||
SELECT
|
||
cs_kn.complex_id, -- complex по domrf_kn
|
||
'objective',
|
||
m.objective_complex_name,
|
||
m.objective_project_id,
|
||
m.match_method,
|
||
m.match_score,
|
||
m.is_reviewed,
|
||
'imported from objective_complex_mapping'
|
||
FROM objective_complex_mapping m
|
||
JOIN complex_sources cs_kn
|
||
ON cs_kn.source = 'domrf_kn'
|
||
AND cs_kn.source_external_id = m.domrf_obj_id
|
||
WHERE m.domrf_obj_id IS NOT NULL
|
||
ON CONFLICT (source, source_id) DO NOTHING;
|
||
|
||
-- ── 5. Backfill orphan-проектов из objective_lots (236 unmatched) ───────────
|
||
|
||
-- Для каждого project_name которого нет ни в complex_sources(source=objective)
|
||
-- — создаём новый complex (берём атрибуты из любой quartiry этого проекта)
|
||
-- + добавляем complex_sources(source=objective).
|
||
|
||
WITH unmatched_objective AS (
|
||
SELECT DISTINCT ON (project_name)
|
||
project_name,
|
||
objective_project_id,
|
||
developer,
|
||
district,
|
||
class AS obj_class,
|
||
city,
|
||
address,
|
||
sales_start_date,
|
||
plan_completion_date,
|
||
actual_completion_date,
|
||
construction_stage
|
||
FROM objective_lots ol
|
||
WHERE NOT EXISTS (
|
||
SELECT 1 FROM complex_sources cs
|
||
WHERE cs.source = 'objective'
|
||
AND cs.source_id = ol.project_name
|
||
)
|
||
ORDER BY project_name, snapshot_date DESC NULLS LAST
|
||
),
|
||
inserted AS (
|
||
INSERT INTO complexes (
|
||
canonical_name, developer_name, region_id, district_name,
|
||
address, obj_class, sales_start_date, plan_completion_date,
|
||
actual_completion_date, site_status, primary_source
|
||
)
|
||
SELECT
|
||
project_name,
|
||
developer,
|
||
66, -- Свердл.обл (Объектив пока только Екб + спутники)
|
||
district,
|
||
address,
|
||
obj_class,
|
||
sales_start_date,
|
||
plan_completion_date,
|
||
actual_completion_date,
|
||
construction_stage,
|
||
'objective'
|
||
FROM unmatched_objective
|
||
RETURNING id, canonical_name
|
||
)
|
||
INSERT INTO complex_sources (
|
||
complex_id, source, source_id, source_external_id,
|
||
match_method, match_score, is_reviewed, note
|
||
)
|
||
SELECT
|
||
i.id,
|
||
'objective',
|
||
uo.project_name,
|
||
uo.objective_project_id,
|
||
'imported',
|
||
1.0,
|
||
FALSE, -- нужно ревью: возможно есть domrf_kn-аналог
|
||
'orphan objective project; consider reviewing for domrf_kn match'
|
||
FROM unmatched_objective uo
|
||
JOIN inserted i ON i.canonical_name = uo.project_name
|
||
ON CONFLICT (source, source_id) DO NOTHING;
|
||
|
||
-- ── 6. Helper-views для запросов ────────────────────────────────────────────
|
||
|
||
-- v_complex_full: полная карточка ЖК с метриками из всех источников.
|
||
DROP VIEW IF EXISTS v_complex_full;
|
||
CREATE VIEW v_complex_full AS
|
||
SELECT
|
||
c.id AS complex_id,
|
||
c.canonical_name,
|
||
c.developer_name,
|
||
c.region_id,
|
||
c.district_name,
|
||
c.obj_class,
|
||
c.address,
|
||
c.latitude,
|
||
c.longitude,
|
||
c.flat_count,
|
||
c.square_living,
|
||
c.site_status,
|
||
-- сводка по источникам
|
||
(SELECT array_agg(source ORDER BY source) FROM complex_sources WHERE complex_id = c.id) AS sources,
|
||
(SELECT COUNT(*) FROM complex_sources WHERE complex_id = c.id) AS sources_count,
|
||
-- domrf_kn obj_id (если есть)
|
||
(SELECT source_external_id FROM complex_sources
|
||
WHERE complex_id = c.id AND source = 'domrf_kn' LIMIT 1) AS domrf_obj_id,
|
||
-- objective project_name (если есть)
|
||
(SELECT source_id FROM complex_sources
|
||
WHERE complex_id = c.id AND source = 'objective' LIMIT 1) AS objective_project_name,
|
||
c.primary_source,
|
||
c.created_at,
|
||
c.updated_at
|
||
FROM complexes c;
|
||
|
||
COMMENT ON VIEW v_complex_full IS
|
||
'Карточка ЖК с агрегированной информацией о всех источниках. '
|
||
'Используй для UI / отчётов / вместо direct lookup в domrf_kn_objects.';
|
||
|
||
-- v_complex_with_objective: ЖК + агрегаты Objective (lots count, sold pct, avg price)
|
||
DROP VIEW IF EXISTS v_complex_with_objective;
|
||
CREATE VIEW v_complex_with_objective AS
|
||
SELECT
|
||
c.id AS complex_id,
|
||
c.canonical_name,
|
||
c.district_name,
|
||
c.obj_class,
|
||
cs.source_id AS objective_project_name,
|
||
COUNT(ol.id) AS objective_lots_n,
|
||
COUNT(ol.id) FILTER (WHERE ol.is_sold) AS objective_sold_n,
|
||
ROUND(100.0 * COUNT(ol.id) FILTER (WHERE ol.is_sold)
|
||
/ NULLIF(COUNT(ol.id), 0), 1) AS objective_sold_pct,
|
||
AVG(ol.price_per_m2_rub) FILTER (WHERE ol.is_sold AND ol.price_per_m2_rub > 0) AS avg_sold_price_per_m2,
|
||
AVG(ol.area_pd) FILTER (WHERE ol.area_pd > 0) AS avg_area_pd,
|
||
MIN(ol.registration_date) AS first_reg_date,
|
||
MAX(ol.registration_date) AS last_reg_date
|
||
FROM complexes c
|
||
JOIN complex_sources cs
|
||
ON cs.complex_id = c.id AND cs.source = 'objective'
|
||
JOIN objective_lots ol
|
||
ON ol.project_name = cs.source_id
|
||
GROUP BY c.id, c.canonical_name, c.district_name, c.obj_class, cs.source_id;
|
||
|
||
COMMENT ON VIEW v_complex_with_objective IS
|
||
'ЖК + агрегаты Objective per-flat: sold count/pct, средняя цена, '
|
||
'диапазон reg_date. NULL во всех колонках = ЖК без Objective-источника.';
|
||
|
||
COMMIT;
|