From 44dbbbeaeeb90aed9f3f2a788052bc15e122ef59 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 28 Jun 2026 21:02:19 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein):=20=D0=B4=D0=B5=D0=B4=D1=83=D0=BF?= =?UTF-8?q?=20=D0=BE=D0=B1=D1=8A=D1=8F=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B9?= =?UTF-8?q?=20=D0=BF=D0=BE=20=D0=BA=D0=B2=D0=B0=D1=80=D1=82=D0=B8=D1=80?= =?UTF-8?q?=D0=B5=20(=D0=BA=D0=BE=D0=BC=D0=BD=D0=B0=D1=82=D1=8B+=D0=BF?= =?UTF-8?q?=D0=BB=D0=BE=D1=89=D0=B0=D0=B4=D1=8C+=D1=8D=D1=82=D0=B0=D0=B6)?= =?UTF-8?q?=20=E2=80=94=20=D0=BA=D1=80=D0=BE=D1=81=D1=81-=D0=BF=D0=BB?= =?UTF-8?q?=D0=BE=D1=89=D0=B0=D0=B4=D0=BE=D1=87=D0=BD=D1=8B=D0=B5=20=D0=B4?= =?UTF-8?q?=D1=83=D0=B1=D0=BB=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../148_dedup_apartments_in_sale_share.sql | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 tradein-mvp/backend/data/sql/148_dedup_apartments_in_sale_share.sql diff --git a/tradein-mvp/backend/data/sql/148_dedup_apartments_in_sale_share.sql b/tradein-mvp/backend/data/sql/148_dedup_apartments_in_sale_share.sql new file mode 100644 index 00000000..0edfb6bd --- /dev/null +++ b/tradein-mvp/backend/data/sql/148_dedup_apartments_in_sale_share.sql @@ -0,0 +1,118 @@ +-- 148_dedup_apartments_in_sale_share.sql +-- +-- CONTEXT: «доля квартир дома в продаже» завышалась кросс-площадочными дублями. +-- Одна физическая квартира, выставленная одновременно на avito + cian + domclick, +-- получает РАЗНЫЕ dedup_hash (наш dedup-merge мигр. 135 склеивает только одинаковые +-- source-карточки, не межплощадочные) → считалась как несколько объявлений. +-- +-- Подтверждено на проде. Пример — ул. Искровцев, 33 (house_id 11176): +-- 4 активных vtorichka-объявления = всего 2 реальные квартиры +-- · 2-комн / 49 м² / 1 этаж — domclick + avito (одинаковая цена) +-- · 3-комн / 63.7 м² / 1 этаж — avito + cian (одинаковая цена) +-- По всей выборке завышение ≈ 19–21% (active_secondary 23401 → 18524, −20.8%; +-- listings_45d 32277 → 25905, −19.7%). +-- +-- WHAT: оба числителя v_building_sale_share переводим со счёта ОБЪЯВЛЕНИЙ на счёт +-- уникальных КВАРТИР — сигнатура (rooms, round(area_m2), floor): +-- active_secondary = count(DISTINCT (rooms, round(area_m2), floor)) +-- FILTER (is_active AND vtorichka) -- было count(*) +-- listings_45d = count(DISTINCT (rooms, round(area_m2), floor)) +-- FILTER (vtorichka AND last_seen_at>=now()-45d) +-- -- было count(DISTINCT dedup_hash) +-- Всё остальное (медианы, avg_days_on_market, оба процента с плаузибилити-гейтом, +-- flat_count_effective, WHERE) — БЕЗ изменений. Оба процента подхватывают +-- дедуплицированные числители автоматически. +-- +-- NULL handling: объявление с NULL площадью/этажом даёт ROW-сигнатуру с NULL-компонентой. +-- count(DISTINCT ROW(...)) трактует частично-NULL кортежи как РАЗНЫЕ значения и +-- игнорирует только полностью-NULL строку (rooms И area_m2 И floor все NULL) — т.е. +-- объявления без характеристик в худшем случае считаются по-отдельности (как раньше), +-- дедуп их не схлопывает. Приемлемо: завышение строго уменьшается, не растёт +-- (проверено: 0 домов, где числитель ВЫРОС). +-- +-- DEPENDENCIES: 143 (view + houses.gar_*), 144 (canon match → gar_flat_count), +-- 145 (плаузибилити-гейт знаменателя), 146 (listings_45d + sale_share_pct_45d + zhkh +-- в COALESCE). Базируется на текущем (146) определении view — меняем ТОЛЬКО числители. +-- listings.rooms (int), listings.area_m2 (numeric), listings.floor (int) — существующие. +-- +-- SAFETY / IDEMPOTENCY: только CREATE OR REPLACE VIEW (структура колонок не меняется) + +-- COMMENT. Повторный прогон — no-op. Деплой-раннер гонит файл через +-- psql -v ON_ERROR_STOP=on БЕЗ --single-transaction → транзакцию открывает САМ файл +-- (BEGIN/COMMIT ниже), как 143/145/146. +-- +-- Deploy order: после 147_canon_strip_geo_prefixes.sql. + +BEGIN; + +CREATE OR REPLACE VIEW v_building_sale_share AS + WITH listing_agg AS ( + SELECT l.house_id_fk AS house_id, + count(DISTINCT (l.rooms, round(l.area_m2), l.floor)) FILTER (WHERE l.is_active AND l.listing_segment = 'vtorichka'::text) AS active_secondary, + count(DISTINCT (l.rooms, round(l.area_m2), l.floor)) FILTER ( + WHERE l.listing_segment = 'vtorichka'::text + AND l.last_seen_at >= (now() - interval '45 days') + ) AS listings_45d, + percentile_cont(0.5::double precision) WITHIN GROUP (ORDER BY (l.price_rub::double precision)) + FILTER (WHERE l.is_active AND l.listing_segment = 'vtorichka'::text AND l.price_rub IS NOT NULL) AS median_price_rub, + percentile_cont(0.5::double precision) WITHIN GROUP (ORDER BY (l.price_per_m2::double precision)) + FILTER (WHERE l.is_active AND l.listing_segment = 'vtorichka'::text AND l.price_per_m2 IS NOT NULL) AS median_price_per_m2, + avg(l.days_on_market) + FILTER (WHERE l.is_active AND l.listing_segment = 'vtorichka'::text AND l.days_on_market IS NOT NULL) AS avg_days_on_market + FROM listings l + WHERE l.house_id_fk IS NOT NULL + GROUP BY l.house_id_fk + ) + SELECT h.id AS house_id, + h.short_address, + h.full_address, + h.address, + h.lat, + h.lon, + h.year_built, + h.house_type, + h.total_floors, + h.series_name, + h.is_emergency, + COALESCE(h.gar_flat_count, h.zhkh_flat_count, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0)) AS flat_count_effective, + h.gar_flat_count, + h.gar_match_method, + la.active_secondary, + la.median_price_rub, + la.median_price_per_m2, + la.avg_days_on_market, + CASE + WHEN COALESCE(h.gar_flat_count, h.zhkh_flat_count, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0)) + >= GREATEST(COALESCE(h.total_floors, 0), 8) + AND la.active_secondary <= COALESCE(h.gar_flat_count, h.zhkh_flat_count, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0)) + THEN round(100.0 * la.active_secondary::numeric + / COALESCE(h.gar_flat_count, h.zhkh_flat_count, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0))::numeric, 1) + ELSE NULL::numeric + END AS sale_share_pct, + la.listings_45d, + CASE + WHEN COALESCE(h.gar_flat_count, h.zhkh_flat_count, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0)) + >= GREATEST(COALESCE(h.total_floors, 0), 8) + AND la.listings_45d <= COALESCE(h.gar_flat_count, h.zhkh_flat_count, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0)) + THEN round(100.0 * la.listings_45d::numeric + / COALESCE(h.gar_flat_count, h.zhkh_flat_count, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0))::numeric, 1) + ELSE NULL::numeric + END AS sale_share_pct_45d + FROM houses h + JOIN listing_agg la ON la.house_id = h.id + WHERE h.geom IS NOT NULL AND (la.active_secondary > 0 OR la.listings_45d > 0); + +COMMENT ON VIEW v_building_sale_share IS + 'Per-building rollup вторички для «доли квартир дома в продаже» (мигр. 143; знаменатель — ' + 'ГАР canon-match мигр. 144; 2-й источник ЖКХ + окно 45д мигр. 146; дедуп кросс-площадочных ' + 'дублей мигр. 148). flat_count_effective = COALESCE(gar_flat_count, zhkh_flat_count, ' + 'NULLIF(total_units,0), NULLIF(flat_count,0)). Оба числителя считают УНИКАЛЬНЫЕ КВАРТИРЫ по ' + 'сигнатуре count(DISTINCT (rooms, round(area_m2), floor)), а не объявления: одна квартира на ' + 'avito+cian+domclick = разные dedup_hash, но одна сигнатура. active_secondary = FILTER ' + '(is_active AND vtorichka); listings_45d = FILTER (vtorichka AND last_seen_at>=now()-45d). ' + 'sale_share_pct = active_secondary/denom; sale_share_pct_45d = listings_45d/denom. Оба под ' + 'плаузибилити-гейтом (denom>=GREATEST(total_floors,8) AND числитель<=denom; мигр. 145), иначе ' + 'NULL. Частично-NULL сигнатуры считаются раздельно (count(DISTINCT ROW) игнорит лишь полностью-' + 'NULL строку). Фильтр: geom NOT NULL AND (active_secondary>0 OR listings_45d>0) — churn-only ' + 'дома (0 активных, но есть листинги за 45д) тоже видны.'; + +COMMIT;