-- ЖК как 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;