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
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:
parent
479f01ae1a
commit
c6bfce3d1a
3 changed files with 196 additions and 0 deletions
33
data/sql/158_pzz_zones_ekb_nulls_not_distinct.sql
Normal file
33
data/sql/158_pzz_zones_ekb_nulls_not_distinct.sql
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
Loading…
Add table
Reference in a new issue