diff --git a/data/sql/158_pzz_zones_ekb_nulls_not_distinct.sql b/data/sql/158_pzz_zones_ekb_nulls_not_distinct.sql new file mode 100644 index 00000000..4542e3c3 --- /dev/null +++ b/data/sql/158_pzz_zones_ekb_nulls_not_distinct.sql @@ -0,0 +1,33 @@ +-- 158_pzz_zones_ekb_nulls_not_distinct.sql +-- #1361 — применить UNIQUE NULLS NOT DISTINCT (rosreestr_id) на СУЩЕСТВУЮЩЕМ проде. +-- +-- ПОЧЕМУ ОТДЕЛЬНАЯ МИГРАЦИЯ. Правка inline-констрейнта в 85_pzz_zones_ekb.sql +-- (CREATE TABLE IF NOT EXISTS) НЕ долетает до прода: 85 уже в _schema_migrations +-- → на деплое пропускается, таблица не пересоздаётся, констрейнт остаётся старым +-- `UNIQUE (rosreestr_id)` (NULLS DISTINCT). Эта миграция меняет констрейнт на живой +-- таблице, чтобы фикс #1361 реально вступил в силу (правка 85 — для fresh-install). +-- +-- БАГ #1361. rosreestr_id nullable (PKK6 при f=geojson иногда кладёт OBJECTID в +-- feature-level id, а не в properties → loader получает NULL). При обычном UNIQUE +-- NULL != NULL → ON CONFLICT (rosreestr_id) НЕ матчит существующие NULL-строки → +-- каждый повторный sync копит дубли зон. NULLS NOT DISTINCT (PG15+) делает NULL +-- сопоставимым → ON CONFLICT матчит → дубли не копятся. +-- +-- БЕЗОПАСНОСТЬ. Проверено на проде 2026-06-15: pzz_zones_ekb ПУСТА (0 строк), +-- текущий констрейнт = pzz_zones_ekb_rosreestr_id_key UNIQUE (rosreestr_id). На пустой +-- таблице DROP+ADD не теряет данных и не может упасть на дублях. Прецедент NULLS NOT +-- DISTINCT в репо: м.110 (uq_infra_dedupe), м.125, м.140. Prod = PG16. +-- Idempotent: DROP CONSTRAINT IF EXISTS + повторный ADD под gate _schema_migrations +-- (ровно один раз по NN-порядку). +-- Apply after: 157_analyze_hotpath_indexes.sql + +BEGIN; + +ALTER TABLE pzz_zones_ekb + DROP CONSTRAINT IF EXISTS pzz_zones_ekb_rosreestr_id_key; + +ALTER TABLE pzz_zones_ekb + ADD CONSTRAINT pzz_zones_ekb_rosreestr_id_key + UNIQUE NULLS NOT DISTINCT (rosreestr_id); + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/108_clean_avito_addresses_v2.sql b/tradein-mvp/backend/data/sql/108_clean_avito_addresses_v2.sql new file mode 100644 index 00000000..0c36b215 --- /dev/null +++ b/tradein-mvp/backend/data/sql/108_clean_avito_addresses_v2.sql @@ -0,0 +1,27 @@ +-- 108_clean_avito_addresses_v2.sql +-- #1419 — повторный backfill очистки listings.address (Avito) на СУЩЕСТВУЮЩЕМ проде. +-- +-- ПОЧЕМУ ОТДЕЛЬНАЯ МИГРАЦИЯ. Правка regexp в 062_clean_avito_addresses.sql НЕ долетает +-- до прода: 062 уже в _schema_migrations → на деплое пропускается, ранее накопленные +-- «грязные» адреса (хвост «Площадь NN.N м²», «от N мин.») остаются. Эта миграция +-- прогоняет улучшенный UPDATE на живой таблице (062 — для fresh-install). +-- +-- ЧТО ЧИНИТ. Старый паттерн `Площадь \d` отрезал только до первой цифры площади и +-- не покрывал десятичные/единицы → хвост «Площадь 45.3 м²» оставался в адресе. Новый +-- паттерн `Площадь \d+(\.\d+)?\s?(м²|м2|кв\.?\s?м)` срезает полностью. +-- Idempotent: regexp_replace на уже-чистой строке — no-op; WHERE отбирает только +-- ещё-грязные строки. Под gate _schema_migrations (ровно один раз). +-- Apply after: 107_scrape_schedules_seed_cian_city_sweep.sql + +BEGIN; + +UPDATE listings + SET address = trim(both ' ,.' FROM + regexp_replace( + regexp_replace(address, '\.?css-[a-z0-9_-]+\s*\{[^}]*\}', '', 'gi'), + '\s*(Площадь \d+(\.\d+)?\s?(м²|м2|кв\.?\s?м)|от \d+\s?мин\.|css-[a-z0-9_-]+).*$', '', 'i' + )) + WHERE source = 'avito' + AND (address ~* 'css-[a-z0-9_-]+' OR address ~* 'Площадь \d+(\.\d+)?\s?(м²|м2|кв\.?\s?м)|от \d+\s?мин\.'); + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/109_asking_to_sold_ratio_segment_filter.sql b/tradein-mvp/backend/data/sql/109_asking_to_sold_ratio_segment_filter.sql new file mode 100644 index 00000000..8febf30b --- /dev/null +++ b/tradein-mvp/backend/data/sql/109_asking_to_sold_ratio_segment_filter.sql @@ -0,0 +1,136 @@ +-- 109_asking_to_sold_ratio_segment_filter.sql +-- #1364/#1186 — пересчитать asking_to_sold_ratios с secondary-only asking на ПРОДЕ. +-- +-- ПОЧЕМУ ОТДЕЛЬНАЯ МИГРАЦИЯ. Правка derivation в 080_asking_to_sold_ratios.sql +-- (добавлен `listing_segment IS NULL OR = 'vtorichka'`) НЕ долетает до прода: 080 уже +-- в _schema_migrations → пропускается. Ранее посеянные ratio посчитаны ВКЛЮЧАЯ +-- новостройки на asking-стороне (на проде 2026-06-15: novostroyki = 9647 из ~19.5k +-- активных listings — почти половина), что систематически искажает median(asking ppm²) +-- и, как следствие, sold/asking ratio. Эта миграция пере-деривирует коэффициенты на +-- secondary-only asking (080 — для fresh-install). +-- +-- ПОЧЕМУ DELETE ПЕРЕД INSERT. ON CONFLICT DO UPDATE обновляет только бакеты, прошедшие +-- порог 30/30 в НОВОЙ выборке. Бакет, у которого после отсева новостроек n_listings +-- падает ниже 30, выпадает из SELECT → стейл-строка (basis='per_rooms') осталась бы. +-- Чистим district='' (в #648 район всегда '') и сеем заново — estimator для выпавшего +-- бакета корректно делает fallback на global -1. +-- +-- Derivation байт-в-байт повторяет 080 (CTE deal_side/ask_side/per_bucket/global_row, +-- окно 12 мес, ppm² [30000,600000], бакет LEAST(GREATEST(rooms,0),4)) — с secondary-only +-- guard на обеих asking-сторонах. Idempotent (DELETE+ON CONFLICT DO UPDATE), под gate +-- _schema_migrations (ровно один раз). Stage 4 asking_to_sold_ratio_refresh продолжит +-- держать ratio актуальным той же логикой. +-- ЗАВИСИМОСТИ: deals, listings (002_core_tables.sql), listings.listing_segment (м.011), +-- asking_to_sold_ratios (080). +-- Apply after: 108_clean_avito_addresses_v2.sql + +BEGIN; + +-- Сброс прежних (novostroyki-загрязнённых) коэффициентов перед чистым пере-севом. +DELETE FROM asking_to_sold_ratios WHERE district = ''; + +WITH +deal_side AS ( + SELECT + LEAST(GREATEST(rooms, 0), 4) AS rooms_bucket, + percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS sold_median, + COUNT(*) AS n_deals + FROM deals + WHERE source = 'rosreestr' + AND rooms IS NOT NULL + AND price_per_m2 BETWEEN 30000 AND 600000 + AND deal_date >= CURRENT_DATE - INTERVAL '12 months' + GROUP BY LEAST(GREATEST(rooms, 0), 4) +), +ask_side AS ( + SELECT + LEAST(GREATEST(rooms, 0), 4) AS rooms_bucket, + percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS ask_median, + COUNT(*) AS n_listings + FROM listings + WHERE is_active + AND rooms IS NOT NULL + AND price_per_m2 BETWEEN 30000 AND 600000 + -- novostroyki guard (#1364/#1186): NULL = legacy вторичка до м.011. + AND (listing_segment IS NULL OR listing_segment = 'vtorichka') + GROUP BY LEAST(GREATEST(rooms, 0), 4) +), +per_bucket AS ( + SELECT + d.rooms_bucket, + ''::text AS district, + (d.sold_median / a.ask_median)::numeric AS ratio, + round(d.sold_median)::bigint AS sold_median, + round(a.ask_median)::bigint AS ask_median, + d.n_deals::int AS n_deals, + a.n_listings::int AS n_listings, + 12 AS window_months, + 'per_rooms'::text AS basis + FROM deal_side d + JOIN ask_side a USING (rooms_bucket) + WHERE d.n_deals >= 30 + AND a.n_listings >= 30 + AND a.ask_median IS NOT NULL + AND a.ask_median > 0 + AND d.sold_median IS NOT NULL + AND d.sold_median > 0 +), +deal_global AS ( + SELECT + percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS sold_median, + COUNT(*) AS n_deals + FROM deals + WHERE source = 'rosreestr' + AND rooms IS NOT NULL + AND price_per_m2 BETWEEN 30000 AND 600000 + AND deal_date >= CURRENT_DATE - INTERVAL '12 months' +), +ask_global AS ( + SELECT + percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS ask_median, + COUNT(*) AS n_listings + FROM listings + WHERE is_active + AND rooms IS NOT NULL + AND price_per_m2 BETWEEN 30000 AND 600000 + -- novostroyki guard (#1364/#1186): NULL = legacy вторичка до м.011. + AND (listing_segment IS NULL OR listing_segment = 'vtorichka') +), +global_row AS ( + SELECT + -1 AS rooms_bucket, + ''::text AS district, + (d.sold_median / a.ask_median)::numeric AS ratio, + round(d.sold_median)::bigint AS sold_median, + round(a.ask_median)::bigint AS ask_median, + d.n_deals::int AS n_deals, + a.n_listings::int AS n_listings, + 12 AS window_months, + 'global_fallback'::text AS basis + FROM deal_global d + CROSS JOIN ask_global a + WHERE a.ask_median IS NOT NULL + AND a.ask_median > 0 + AND d.sold_median IS NOT NULL + AND d.sold_median > 0 +) +INSERT INTO asking_to_sold_ratios ( + rooms_bucket, district, ratio, sold_median, ask_median, + n_deals, n_listings, window_months, basis +) +SELECT rooms_bucket, district, ratio, sold_median, ask_median, + n_deals, n_listings, window_months, basis FROM global_row +UNION ALL +SELECT rooms_bucket, district, ratio, sold_median, ask_median, + n_deals, n_listings, window_months, basis FROM per_bucket +ON CONFLICT (rooms_bucket, district) DO UPDATE SET + ratio = EXCLUDED.ratio, + sold_median = EXCLUDED.sold_median, + ask_median = EXCLUDED.ask_median, + n_deals = EXCLUDED.n_deals, + n_listings = EXCLUDED.n_listings, + window_months = EXCLUDED.window_months, + basis = EXCLUDED.basis, + computed_at = now(); + +COMMIT;