feat(tradein): схема «доли квартир дома в продаже» — gar_house_flats + houses.gar_* + v_building_sale_share (143) #2053

Merged
lekss361 merged 1 commit from feat/tradein-sale-share-schema into main 2026-06-28 13:48:42 +00:00

View file

@ -0,0 +1,167 @@
-- 143_building_sale_share_schema.sql
--
-- CONTEXT: схема-фундамент новой страницы «доля квартир дома в продаже» —
-- список домов ВТОРИЧНОГО рынка, где
-- active_vtorichka_listings / total_apartments_in_building >= threshold%.
-- Числитель (активные вторичные объявления на дом) у нас уже есть в `listings`
-- (house_id_fk + is_active + listing_segment='vtorichka'). Знаменатель —
-- «сколько всего квартир в доме» — на проде заполнен слабо (total_units/flat_count
-- неполные/шумные), поэтому отдельно паримся над ГАР/ФИАС-дампом по региону 66 и
-- матчим к houses по НОРМАЛИЗОВАННОМУ адресу (tradein_normalize_short_addr).
--
-- Этот файл — ТОЛЬКО схема (таблица-приёмник ГАР + provenance-колонки в houses +
-- rollup-view). Сам парс/загрузка ГАР и backfill houses.gar_* — отдельная задача;
-- до неё view опирается на fallback-знаменатель (total_units / flat_count).
--
-- WHAT:
-- 1. Таблица gar_house_flats — «квартир на дом» из ГАР (region-66, ключ — адрес/GUID).
-- 2. houses.gar_* — provenance матча ГАР→houses (guid, flat_count, matched_at, method).
-- 3. v_building_sale_share — per-building rollup вторички с эффективным знаменателем
-- COALESCE(gar_flat_count, total_units, flat_count) и sale_share_pct.
--
-- DEPENDENCIES (existing prod objects):
-- - houses (id, total_units, flat_count, geom Point/4326, short_address,
-- full_address, address, lat, lon, year_built, house_type, total_floors,
-- series_name, is_emergency) — core tables.
-- - listings (house_id_fk → houses.id, is_active, listing_segment, price_rub,
-- price_per_m2, days_on_market) — core tables.
-- - tradein_normalize_short_addr(text) — нормализатор адреса (используется
-- loader'ом ГАР для заполнения norm_address; в этом файле прямо не вызывается).
-- - pg_trgm extension — создаётся здесь IF NOT EXISTS (нужен для trgm-GIN на
-- norm_address: fuzzy-матч ГАР↔houses по адресу на следующем шаге).
--
-- SAFETY / IDEMPOTENCY:
-- - НЕ деструктивно: только CREATE TABLE/INDEX IF NOT EXISTS, ADD COLUMN IF NOT
-- EXISTS, CREATE OR REPLACE VIEW. Повторный прогон — no-op.
-- - NB: раннер деплоя (deploy-tradein.yml) гонит файл через psql -v
-- ON_ERROR_STOP=on БЕЗ --single-transaction → транзакцию открывает САМ файл
-- (BEGIN/COMMIT ниже), как 137/134/133.
--
-- NB по нумерации: исходно планировался как «139», но на forgejo/main 139142
-- уже заняты (139_premium_houses … 142_premium_buildings_curated) → следующий
-- свободный sequential = 143 (проверено `ls | grep '^143'`).
--
-- Deploy order: после 142_premium_buildings_curated.sql.
BEGIN;
-- pg_trgm нужен для GIN-trgm индекса на norm_address (fuzzy ГАР↔houses match).
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- ─────────────────────────────────────────────────────────────────────
-- 1. gar_house_flats — «квартир на дом» из ГАР (region-66), address-keyed
-- ─────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS gar_house_flats (
house_guid text PRIMARY KEY, -- ГАР OBJECTGUID дома
object_id bigint, -- ГАР OBJECTID
region_code text NOT NULL,
flat_count int, -- # помещений-квартир под домом
full_address text,
norm_address text, -- tradein_normalize_short_addr(full_address)
street_name text,
house_num text,
loaded_at timestamptz NOT NULL DEFAULT now(),
gar_version text
);
CREATE INDEX IF NOT EXISTS gar_house_flats_norm_address_idx
ON gar_house_flats (norm_address);
CREATE INDEX IF NOT EXISTS gar_house_flats_norm_address_trgm_idx
ON gar_house_flats USING gin (norm_address gin_trgm_ops);
CREATE INDEX IF NOT EXISTS gar_house_flats_region_code_idx
ON gar_house_flats (region_code);
COMMENT ON TABLE gar_house_flats IS
'ГАР/ФИАС: квартир на дом (region-66), ключ house_guid (OBJECTGUID). '
'norm_address = tradein_normalize_short_addr(full_address) для матча к houses. '
'Заполняется отдельным loader''ом из self-parsed ГАР-дампа (мигр. 143).';
-- ─────────────────────────────────────────────────────────────────────
-- 2. houses — provenance-колонки матча ГАР→houses
-- ─────────────────────────────────────────────────────────────────────
ALTER TABLE houses ADD COLUMN IF NOT EXISTS gar_house_guid text;
ALTER TABLE houses ADD COLUMN IF NOT EXISTS gar_flat_count int;
ALTER TABLE houses ADD COLUMN IF NOT EXISTS gar_matched_at timestamptz;
ALTER TABLE houses ADD COLUMN IF NOT EXISTS gar_match_method text;
-- partial: индексируем только реально заматченные дома (gar_flat_count populated).
CREATE INDEX IF NOT EXISTS houses_gar_flat_count_idx
ON houses (gar_flat_count)
WHERE gar_flat_count IS NOT NULL;
-- ─────────────────────────────────────────────────────────────────────
-- 3. v_building_sale_share — per-building rollup вторички
-- Эффективный знаменатель = COALESCE(ГАР, total_units, flat_count).
-- Строки с NULL-знаменателем НЕ отбрасываем (sale_share_pct=NULL) —
-- coverage-запрос должен уметь их посчитать.
-- ─────────────────────────────────────────────────────────────────────
CREATE OR REPLACE VIEW v_building_sale_share AS
WITH listing_agg AS (
-- агрегат активных вторичных объявлений на дом (keyed на house_id_fk)
SELECT
l.house_id_fk AS house_id,
count(*) FILTER (
WHERE l.is_active AND l.listing_segment = 'vtorichka'
) AS active_secondary,
percentile_cont(0.5) WITHIN GROUP (ORDER BY l.price_rub)
FILTER (
WHERE l.is_active AND l.listing_segment = 'vtorichka'
AND l.price_rub IS NOT NULL
) AS median_price_rub,
percentile_cont(0.5) WITHIN GROUP (ORDER BY l.price_per_m2)
FILTER (
WHERE l.is_active AND l.listing_segment = 'vtorichka'
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'
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, 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, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0)) > 0
THEN round(
100.0 * la.active_secondary
/ COALESCE(h.gar_flat_count, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0)),
1
)
ELSE NULL
END AS sale_share_pct
FROM houses h
JOIN listing_agg la ON la.house_id = h.id
WHERE h.geom IS NOT NULL
AND la.active_secondary > 0;
COMMENT ON VIEW v_building_sale_share IS
'Per-building rollup вторички для страницы «доля квартир дома в продаже» (мигр. 143). '
'flat_count_effective = COALESCE(gar_flat_count, NULLIF(total_units,0), NULLIF(flat_count,0)); '
'sale_share_pct = round(100*active_secondary/flat_count_effective,1) только при denom>0 '
'(иначе NULL — строка сохраняется для coverage-подсчёта). Фильтр: geom NOT NULL AND '
'active_secondary>0.';
COMMIT;