gendesign/tradein-mvp/backend/data/sql/094_cadastral_unify.sql
bot-backend 42de30cd5c refactor(tradein): unify kadastr_num -> cadastral_number (#732)
p3 naming-consistency (cadastral cols 0% filled). Migration 094_cadastral_unify.sql
(idempotent DO-block IF EXISTS): drop+recreate listings_search_mv (MV) +
v_data_quality (view), RENAME deals/house_metadata kadastr_num->cadastral_number,
DROP listings.kadastr_num + listings_kadastr_idx. Code SQL-string renames in
search_query/estimator/base (Lot dataclass field NOT renamed). 240 tests pass.

WARN: destructive DDL auto-applies to prod on deploy -> MERGE-GATE post-demo.
Reviewer: verify recreated MV/view DDL vs LIVE pg_get_viewdef before apply
(reconstructed from migration history 050/046, not live capture).

Refs #732
2026-05-31 15:41:52 +03:00

230 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.

-- 094_cadastral_unify.sql
-- Purpose: Унификация имени кадастрового столбца kadastr_num → cadastral_number
-- во всех таблицах tradein-БД.
--
-- Контекст (Issue #732):
-- deals.kadastr_num → RENAME TO cadastral_number
-- house_metadata.kadastr_num → RENAME TO cadastral_number
-- listings.kadastr_num → DROP (данные 0%; listings.cadastral_number уже существует
-- — добавлен в 019_listings_alter_cian.sql)
-- listings_search_mv → DROP + RECREATE (ссылался на l.kadastr_num → l.cadastral_number)
-- v_data_quality → DROP + RECREATE (ссылался на kadastr_num в active_listings CTE)
--
-- Idempotency:
-- * RENAME: обёрнуты в DO $$ ... $$ блок с проверкой information_schema.columns.
-- Повторный запуск: столбец уже переименован → IF NOT EXISTS → skip.
-- * DROP/DROP IF EXISTS: безопасны при повторном запуске.
-- * RECREATE: CREATE OR REPLACE VIEW / CREATE MATERIALIZED VIEW после DROP IF EXISTS.
--
-- ВНИМАНИЕ: DESTRUCTIVE DDL. Применять только после проверки данных на проде.
-- AUTO-APPLIES ON DEPLOY — merge-gate требуется (post-demo).
--
-- Dependencies:
-- 002_core_tables.sql (deals, listings, house_metadata)
-- 019_listings_alter_cian.sql (listings.cadastral_number already exists)
-- 046_views.sql (v_data_quality, v_price_divergence)
-- 050_search_optimization.sql (listings_search_mv)
BEGIN;
-- =============================================================================
-- 1. Drop dependent objects (view + materialized view)
-- Нужно до RENAME/DROP колонок, иначе PG откажет (зависимые объекты).
-- =============================================================================
DROP MATERIALIZED VIEW IF EXISTS listings_search_mv;
DROP VIEW IF EXISTS v_data_quality;
-- =============================================================================
-- 2. Rename deals.kadastr_num → cadastral_number (idempotent)
-- =============================================================================
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'deals'
AND column_name = 'kadastr_num'
) THEN
ALTER TABLE deals RENAME COLUMN kadastr_num TO cadastral_number;
END IF;
END
$$;
-- =============================================================================
-- 3. Rename house_metadata.kadastr_num → cadastral_number (idempotent)
-- house_metadata.kadastr_num имеет UNIQUE constraint — переименование сохраняет его.
-- =============================================================================
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'house_metadata'
AND column_name = 'kadastr_num'
) THEN
ALTER TABLE house_metadata RENAME COLUMN kadastr_num TO cadastral_number;
END IF;
END
$$;
-- =============================================================================
-- 4. Удаляем listings.kadastr_num + его индекс
-- listings.cadastral_number (кадастровый номер квартиры, из Cian) уже существует
-- с 019_listings_alter_cian.sql — данные в kadastr_num 0%, копировать не нужно.
-- =============================================================================
DROP INDEX IF EXISTS listings_kadastr_idx;
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'listings'
AND column_name = 'kadastr_num'
) THEN
ALTER TABLE listings DROP COLUMN kadastr_num;
END IF;
END
$$;
-- =============================================================================
-- 5. Recreate v_data_quality
-- Изменение: kadastr_num → cadastral_number (listings.cadastral_number из 019).
-- CREATE OR REPLACE VIEW — идемпотентно.
-- =============================================================================
CREATE OR REPLACE VIEW v_data_quality AS
WITH active_listings AS (
SELECT * FROM listings WHERE is_active = true
)
SELECT
(SELECT count(*) FROM houses) AS houses_total,
(SELECT count(*) FROM houses h
WHERE EXISTS (SELECT 1 FROM house_sources hs WHERE hs.house_id = h.id)) AS houses_with_source,
(SELECT count(*) FROM houses h
WHERE EXISTS (SELECT 1 FROM house_sources hs
WHERE hs.house_id = h.id AND hs.ext_source = 'avito')) AS houses_with_avito,
(SELECT count(*) FROM houses h
WHERE EXISTS (SELECT 1 FROM house_sources hs
WHERE hs.house_id = h.id AND hs.ext_source LIKE 'cian%')) AS houses_with_cian,
(SELECT count(*) FROM houses h
WHERE EXISTS (SELECT 1 FROM house_sources hs
WHERE hs.house_id = h.id AND hs.ext_source = 'yandex')) AS houses_with_yandex,
(SELECT count(*) FROM (
SELECT house_id FROM house_sources GROUP BY house_id HAVING count(*) >= 2
) sub) AS houses_2plus_sources,
(SELECT count(*) FROM (
SELECT house_id FROM house_sources GROUP BY house_id HAVING count(*) >= 3
) sub) AS houses_3plus_sources,
(SELECT count(*) FROM active_listings) AS listings_active,
(SELECT count(*) FROM (
SELECT listing_id FROM listing_sources
WHERE listing_id IN (SELECT id FROM active_listings)
GROUP BY listing_id HAVING count(*) >= 2
) sub) AS listings_dedup_2sources,
(SELECT count(*) FROM active_listings WHERE lat IS NOT NULL) * 100.0
/ NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_geocoded,
(SELECT count(*) FROM active_listings WHERE cadastral_number IS NOT NULL) * 100.0
/ NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_cadastr,
(SELECT count(*) FROM active_listings WHERE description IS NOT NULL) * 100.0
/ NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_description,
(SELECT count(*) FROM active_listings l
JOIN houses h ON h.id = l.house_id_fk
WHERE h.year_built IS NOT NULL) * 100.0
/ NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_year_built,
NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'avito') AS avito_last_scrape_ago,
NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'cian') AS cian_last_scrape_ago,
NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'yandex') AS yandex_last_scrape_ago,
(SELECT count(*) FROM v_price_divergence) AS price_disagreements_count,
(SELECT count(*) FROM listings WHERE is_outlier = true) AS outliers_flagged;
COMMENT ON VIEW v_data_quality IS
'KPI snapshot. Refreshed on-demand by /api/v1/admin/data-quality endpoint (Master Plan sec 8.1).';
-- =============================================================================
-- 6. Recreate listings_search_mv
-- Изменение: l.kadastr_num → l.cadastral_number (apartment-level из 019_listings_alter_cian).
-- Все 6 индексов воссоздаются (см. 050_search_optimization.sql).
-- =============================================================================
CREATE MATERIALIZED VIEW listings_search_mv AS
SELECT
l.id AS listing_id,
l.source,
l.source_url,
l.address,
l.geom,
l.lat,
l.lon AS lng,
l.rooms,
l.area_m2 AS total_area,
l.floor,
l.total_floors,
l.price_rub,
l.price_per_m2,
l.cadastral_number,
l.is_active,
l.scraped_at,
-- House denorm
h.id AS house_id,
h.year_built,
h.house_class,
h.developer_name,
h.rating AS house_rating,
h.reviews_count AS house_ratings_count,
-- Cross-source aggregates
(SELECT count(*) FROM listing_sources ls WHERE ls.listing_id = l.id) AS source_count,
(SELECT array_agg(DISTINCT ext_source) FROM listing_sources ls WHERE ls.listing_id = l.id) AS sources,
(SELECT bool_or(ext_source = 'avito') FROM listing_sources ls WHERE ls.listing_id = l.id) AS has_avito,
(SELECT bool_or(ext_source = 'cian') FROM listing_sources ls WHERE ls.listing_id = l.id) AS has_cian,
(SELECT bool_or(ext_source = 'yandex_realty') FROM listing_sources ls WHERE ls.listing_id = l.id) AS has_yandex,
-- Price percentile within house
(SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY ll.price_per_m2)
FROM listings ll
WHERE ll.house_id_fk = l.house_id_fk AND ll.is_active = true) AS house_median_ppm2,
-- DEFAULT: district / distance_to_metro_m / last_price_change / photos_count
-- not present in current schema — placeholders.
NULL::text AS district,
NULL::int AS distance_to_metro_m,
NULL::timestamptz AS last_price_change,
NULL::int AS photos_count,
-- Trigram-ready columns
l.address AS address_trgm,
-- Aggregated tsv (description + address + developer_name)
to_tsvector('russian',
coalesce(l.description, '') || ' ' ||
coalesce(l.address, '') || ' ' ||
coalesce(h.developer_name, '')
) AS tsv
FROM listings l
LEFT JOIN houses h ON h.id = l.house_id_fk
WHERE l.is_active = true
AND COALESCE(l.canonical, true) = true;
-- 6 matview indexes (воссоздаются без IF NOT EXISTS — MV была только что создана)
CREATE UNIQUE INDEX listings_search_mv_id_idx
ON listings_search_mv (listing_id);
CREATE INDEX listings_search_mv_geom_idx
ON listings_search_mv USING GIST (geom);
CREATE INDEX listings_search_mv_filters_idx
ON listings_search_mv (rooms, price_rub, total_area, scraped_at DESC);
CREATE INDEX listings_search_mv_address_trgm_idx
ON listings_search_mv USING GIN (address_trgm gin_trgm_ops);
CREATE INDEX listings_search_mv_tsv_idx
ON listings_search_mv USING GIN (tsv);
CREATE INDEX listings_search_mv_sources_idx
ON listings_search_mv (has_avito, has_cian, has_yandex);
-- Populate materialized view (первый запуск — CONCURRENTLY недоступен на новой MV).
REFRESH MATERIALIZED VIEW listings_search_mv;
COMMIT;