fix(tradein): дедуп объявлений по квартире (комнаты+площадь+этаж) — кросс-площадочные дубли
This commit is contained in:
parent
b45a819cb6
commit
44dbbbeaee
1 changed files with 118 additions and 0 deletions
|
|
@ -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;
|
||||||
Loading…
Add table
Reference in a new issue