-- 94_mv_layout_velocity.sql -- Issue #113 PR B — Materialized view per (obj_id, room_bucket) for top-layouts ranking. -- -- Sources: -- objective_corpus_room_month — monthly per-corpus-per-room deals (19 738 rows, 2025-05..2026-05) -- objective_complex_mapping — project_name → domrf_obj_id mapping (114 mapped objects) -- -- Join key: -- objective_corpus_room_month.project_name = objective_complex_mapping.objective_complex_name -- (verified: one project_name → at most one domrf_obj_id) -- -- room_bucket normalisation: -- cyrillic 'студия' → ASCII 'studio' for consistency with layout_signature.py (PR A, Issue #113) -- values '1', '2', '3', '4+' are kept as-is -- -- Window: last 24 months (rolling, anchored to NOW() at refresh time) -- Expected rows after aggregation: ~459 (obj_id × room_bucket combinations) -- -- REFRESH CONCURRENTLY is safe after this migration because a UNIQUE index -- on (obj_id, room_bucket) is created immediately. -- Subsequent refreshes: layout_velocity_refresh.py helper (PR B) — not scheduled yet. -- -- Deploy: auto-applied by deploy.yml via _schema_migrations tracking. -- Dependencies: none (no views depend on this MV at creation time). -- -- WARN: Re-apply этого файла (DR / lost _schema_migrations entry / dev local) -- снесёт MV + CASCADE удалит зависимости (e.g. future /best-layouts views). -- После re-apply ПЕРВЫЙ refresh должен быть concurrently=False -- (CONCURRENTLY требует уже populated MV — упадёт на пустой). -- Tracking table `_schema_migrations` нормально предотвращает re-apply, -- но добавлен comment как safety reminder. BEGIN; DROP MATERIALIZED VIEW IF EXISTS mv_layout_velocity CASCADE; CREATE MATERIALIZED VIEW mv_layout_velocity AS WITH last24mo AS ( SELECT ocm.project_name, CASE WHEN ocm.room_bucket = 'студия' THEN 'studio' ELSE ocm.room_bucket END AS room_bucket, ocm.deals_total_count, ocm.deals_total_avg_area_m2, ocm.deals_total_avg_price_thousand_rub_per_m2, ocm.deals_total_vol_m2, ocm.report_month FROM objective_corpus_room_month ocm WHERE ocm.report_month >= (NOW() - INTERVAL '24 months')::date ) SELECT cm.domrf_obj_id AS obj_id, l.room_bucket, SUM(l.deals_total_count)::int AS total_deals_24mo, AVG(l.deals_total_avg_area_m2)::numeric(10, 2) AS avg_area_m2, AVG(l.deals_total_avg_price_thousand_rub_per_m2)::numeric(12, 2) AS avg_price_thousand_rub_per_m2, SUM(l.deals_total_vol_m2)::numeric(12, 2) AS total_vol_m2, MIN(l.report_month) AS window_start, MAX(l.report_month) AS window_end, COUNT(DISTINCT l.report_month)::int AS months_with_data FROM last24mo l JOIN objective_complex_mapping cm ON cm.objective_complex_name = l.project_name WHERE l.room_bucket IS NOT NULL AND cm.domrf_obj_id IS NOT NULL AND cm.objective_group = 'Екатеринбург' -- защита от cross-region Cartesian при future multi-city GROUP BY cm.domrf_obj_id, l.room_bucket WITH NO DATA; -- UNIQUE index required for REFRESH CONCURRENTLY (future periodic refreshes) -- Created on empty MV → instant, no lock duration on data CREATE UNIQUE INDEX mv_layout_velocity_pk ON mv_layout_velocity (obj_id, room_bucket); -- Lookup index used by /best-layouts endpoint (PR C) CREATE INDEX mv_layout_velocity_obj_idx ON mv_layout_velocity (obj_id); -- Initial populate (non-concurrent — MV was just created, CONCURRENTLY not allowed on empty MV) REFRESH MATERIALIZED VIEW mv_layout_velocity; COMMENT ON MATERIALIZED VIEW mv_layout_velocity IS 'Per-(obj_id, room_bucket) deals aggregation за last 24 months. ' 'Source: objective_corpus_room_month × objective_complex_mapping (EKB only). ' 'Refresh via layout_velocity_refresh.py (concurrently=True after initial populate).'; COMMIT;