diff --git a/data/sql/100_fix_mv_layout_velocity_weighted_avg.sql b/data/sql/100_fix_mv_layout_velocity_weighted_avg.sql new file mode 100644 index 00000000..23b72863 --- /dev/null +++ b/data/sql/100_fix_mv_layout_velocity_weighted_avg.sql @@ -0,0 +1,88 @@ +-- 100_fix_mv_layout_velocity_weighted_avg.sql +-- SF Bug #21 (P0) — mv_layout_velocity использовал unweighted AVG() по +-- deals_total_avg_area_m2 и deals_total_avg_price_thousand_rub_per_m2. +-- objective_corpus_room_month хранит строки за каждый (project, room, month) +-- включая месяцы с 0 сделок (count=0, area=0, price=0). Нули тянули +-- среднее вниз в 2-14×. Примеры (verified prod): +-- obj 65307, room=3: 114 месяцев, 106 нулевых → AVG=7.4 м² vs weighted=105.8 м² +-- obj 65903, room=4+: 36 мес, 33 нулевых → AVG=9.1 м² vs weighted=109.0 м² +-- +-- Fix: заменяем AVG(x) на SUM(x * count) / NULLIF(SUM(count), 0) — weighted average. +-- +-- Dependencies: mv_layout_velocity не имеет зависимых VIEW/MV (проверено 2026-05-17). +-- Existing indexes: mv_layout_velocity_pk (UNIQUE obj_id, room_bucket), +-- mv_layout_velocity_obj_idx (obj_id) — пересоздаются ниже. +-- +-- ВАЖНО: этот файл выполняет DROP + CREATE + REFRESH (non-concurrent, блокировка ~30 с). +-- REFRESH CONCURRENTLY после первого populate — безопасен через layout_velocity_refresh.py. +-- +-- Deploy order: SQL migration → app deploy (никаких backend изменений в этом PR). +-- Idempotency: DROP IF EXISTS → безопасен при повторном запуске. + +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, + + -- FIX SF #21: weighted average — нули из месяцев без сделок не тянут вниз. + -- Формула: SUM(avg_x * count) / SUM(count) эквивалентна SUM(total_x) / SUM(count), + -- т.к. avg_x = total_x / count за один месяц → произведение восстанавливает total_x. + (SUM(l.deals_total_avg_area_m2 * l.deals_total_count) + / NULLIF(SUM(l.deals_total_count), 0))::numeric(10, 2) AS avg_area_m2, + + (SUM(l.deals_total_avg_price_thousand_rub_per_m2 * l.deals_total_count) + / NULLIF(SUM(l.deals_total_count), 0))::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 = 'Екатеринбург' +GROUP BY cm.domrf_obj_id, l.room_bucket +WITH NO DATA; + +-- UNIQUE index: required for REFRESH CONCURRENTLY (periodic via layout_velocity_refresh.py). +-- GROUP BY (obj_id, room_bucket) guarantees uniqueness. +CREATE UNIQUE INDEX mv_layout_velocity_pk + ON mv_layout_velocity (obj_id, room_bucket); + +-- Lookup index for /best-layouts endpoint queries by obj_id. +CREATE INDEX mv_layout_velocity_obj_idx + ON mv_layout_velocity (obj_id); + +-- Initial populate (non-concurrent — MV just created, CONCURRENTLY requires populated MV). +-- Блокировка ~30 с на production при ~19 738 source rows. +REFRESH MATERIALIZED VIEW mv_layout_velocity; + +COMMENT ON MATERIALIZED VIEW mv_layout_velocity IS + 'Per-(obj_id, room_bucket) deals aggregation за last 24 months. ' + 'WEIGHTED average площади и цены (SF Bug #21 fix, 2026-05-17). ' + 'Source: objective_corpus_room_month × objective_complex_mapping (EKB only). ' + 'Refresh via layout_velocity_refresh.py (CONCURRENTLY после initial populate).'; + +COMMIT;