fix(migrations): живые миграции для прод-фиксов #1361/#1419/#1364
Some checks failed
CI / changes (push) Successful in 10s
CI / changes (pull_request) Successful in 10s
CI / frontend-tests (push) Successful in 1m22s
CI / openapi-codegen-check (push) Failing after 2m22s
CI / frontend-tests (pull_request) Successful in 1m5s
CI / openapi-codegen-check (pull_request) Failing after 1m45s
CI / backend-tests (push) Failing after 9m8s
CI / backend-tests (pull_request) Failing after 8m54s

Правки уже-применённых миграций (85/062/080) не долетают до прода через
_schema_migrations. Дописаны отдельные миграции на живых таблицах:
- 158: pzz_zones_ekb UNIQUE NULLS NOT DISTINCT (rosreestr_id) (#1361)
- tradein 108: повторный backfill Avito-адресов улучшенным regexp (#1419)
- tradein 109: пересчёт asking_to_sold_ratios secondary-only (#1364/#1186)

Все три dry-run (BEGIN..ROLLBACK) против прод-схемы: PSQL_RC=0.
This commit is contained in:
bot-backend 2026-06-15 21:31:28 +03:00
parent 479f01ae1a
commit c6bfce3d1a
3 changed files with 196 additions and 0 deletions

View file

@ -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;

View file

@ -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;

View file

@ -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;