gendesign/tradein-mvp/backend/data/sql/216_dead_code_sweep.sql
bot-backend f5b39e6fc9
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m57s
chore(tradein): разбор мёртвого кода — подключить, удалить или задокументировать (#2674)
Восемь находок «написано, покрыто тестами, ни разу не сработало» разведены на три
разных диагноза. Две из восьми оказались не мёртвым кодом, а оборванной проводкой.

ПОДКЛЮЧЕНО

Загрузчик ДОМ.РФ. Loader и CLI существуют с #2013, а Handler'а в product_handlers
и строки в scrape_schedules не было — вызвать его было нечем. На проде 29 978 строк
staging с ОДНИМ loaded_at (2026-07-12), то есть ровно один ручной запуск, 24 дня
без обновления. Отсюда кормятся houses.year_built/material_walls/total_floors и
дальше listings.year_built — когортный фильтр эстиматора. Недельный такт, окно
03:00-04:00 UTC (до импорта ДКП и дневных агрегатов).

filters_hash. Парсер читал estimation.sale.data.filtersHash, а Циан кладёт ключ
уровнем выше — estimation.sale.filtersHash. Колонка пуста 0/1658, при том что в
сохранённых сырых ответах хеш есть у 139/139 и все значения различны. Путь исправлен,
139 строк восстановлены бэкфиллом из raw_payload.

has_panorama. Разбирался парсером, лежал в карте приоритетов, обещан публичным
контрактом market.v_houses — и не попадал в houses ни одной строкой кода (0 из 9366).
Пишется там, где yandex_valuation уже держит и house_id, и мету. Гейт честности:
парсер отдаёт bool, а не bool|None, поэтому false пишем только при подтверждённо
отрисованной странице (есть год или этажность) — иначе NULL, а не выдуманный false.

УДАЛЕНО

Дедуп-обёртки эстиматора _phys_dedup_key / _extract_street_token: 25 ссылок, все из
тестов. Хуже, чем просто мёртвые — _phys_dedup_key утверждала правило «ключ = кадастр
ИЛИ улица», которого в боевом дедупе нет (_union_find_phys_dedup держит оба композита
и сливает по любому совпадению). Тесты переведены на живые функции.

Тиерные коэффициенты выкупа asking_to_sold_ratios_tiered + asking_to_sold_tier_bounds:
ноль читателей и писателей, флага tier_aware_ratio_enabled не существует. Посчитаны
один раз при накатке 098 (computed_at 2026-06-27) — тогда как живая
asking_to_sold_ratios обновляется ежедневно (2026-08-05). Методика сохранена в 098.

Колонки без писателя: listings.merged_into (113 уже называла её мёртвой) и
house_sources.raw_payload вместе с GIN-индексом по всегда-NULL колонке.

v_data_quality.price_disagreements_count: у всех 89 699 объявлений ровно один
источник, показатель структурно не мог быть ненулевым, а ноль читался как
«расхождений нет».

ЗАДОКУМЕНТИРОВАНО

BROWSER_BLOCK_RESOURCES выставлен во всех трёх прод-контейнерах, а код перестал его
читать в #1812. Блокировка при этом не ослабла (image глушит camoufox block_images,
font/media — дефолт списка типов), мёртв только выключатель. Сервис теперь говорит
об этом на старте: молча игнорируемая ручка опаснее отсутствующей.

v_price_divergence / v_cross_source_health оставлены как задел, но в COMMENT написано,
почему они пусты структурно: боевой путь загрузки зовёт upsert_listing_source
напрямую и не зовёт match_or_create_listing.

house_sources.ext_url пуст 46 813/46 813, но входит в публичный контракт
market.v_house_sources — оставлен и подписан.

Refs #2674
2026-08-06 05:00:36 +05:00

232 lines
18 KiB
PL/PgSQL
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

-- 216_dead_code_sweep.sql
-- Purpose (#2674, раздел «Мёртвый код»): развести три разных вещи, которые снаружи
-- выглядят одинаково — «написано и ни разу не сработало».
--
-- 1. ОБОРВАННАЯ ПРОВОДКА — механизм рабочий, звать некому/нечем. Чиним подключением.
-- 2. МЁРТВОЕ — механизм невыразим, дублирует существующее или потерял смысл. Удаляем.
-- 3. ЗАДЕЛ — оставляем, но в схеме должно быть написано, чем он НЕ является сегодня.
--
-- Все числа — с прод-БД tradein 2026-08-06.
--
-- ── 1. ПОДКЛЮЧАЕМ ───────────────────────────────────────────────────────────
--
-- A. domrf_kapremont_load — расписание для загрузчика ДОМ.РФ.
-- Загрузчик (app/services/domrf_kapremont_loader.py) и CLI
-- (app/tasks/domrf_kapremont_load.py) написаны и покрыты тестами с #2013, но
-- Handler'а в product_handlers и строки в scrape_schedules не существовало —
-- вызвать его было НЕЧЕМ. Итог на проде: 29 978 строк staging, у ВСЕХ один и тот
-- же loaded_at = 2026-07-12 13:19:30 (ровно один ручной запуск), 24 дня без
-- обновления. Это не мёртвый код: houses.year_built/material_walls/total_floors и
-- дальше listings.year_built (когортный фильтр эстиматора) кормятся именно отсюда.
--
-- B. external_valuations.filters_hash — бэкфилл из сырых ответов.
-- Парсер читал estimation.sale.data.filtersHash, а Циан кладёт ключ на УРОВЕНЬ
-- ВЫШЕ — estimation.sale.filtersHash (ключи sale на проде: isError, filtersHash,
-- data, isFetching). Колонка была пуста 0/1658, при том что в сырых ответах хеш
-- есть у 139/139 строк cian_valuation и все 139 значений различны. Путь починен в
-- providers/cian/valuation.py; здесь достаём то, что уже лежит в raw_payload.
--
-- ── 2. УДАЛЯЕМ ──────────────────────────────────────────────────────────────
--
-- C. asking_to_sold_ratios_tiered + asking_to_sold_tier_bounds (мигр. 098, #928).
-- Ноль читателей и ноль писателей в коде — грепом не находится ни одного
-- упоминания вне самой 098 и манифеста. Флага tier_aware_ratio_enabled, под
-- который таблицы задумывались, в конфиге не существует. Посчитаны один раз при
-- накатке (21 + 5 строк, computed_at 2026-06-27) и с тех пор не двигались, тогда
-- как ЖИВАЯ asking_to_sold_ratios обновляется ежедневно (computed_at 2026-08-05).
-- То есть в БД лежат коэффициенты выкупа сорокадневной давности, которые выглядят
-- как рабочая сегментация — их достаточно один раз прочитать по ошибке, чтобы
-- получить оценку по устаревшему рынку. Методика не потеряна: derivation-CTE
-- целиком сохранён в 098, восстановить = переприменить файл.
--
-- D. listings.merged_into — 142 569 строк, NULL у всех, ноль упоминаний в коде.
-- Заведена в 028 «under dedup workflow», который так и не построили; 113 уже
-- писала прямым текстом «column is dead, no code writer». Дедуп объявлений живёт
-- в другом месте и по-другому (estimator._union_find_phys_dedup, во время оценки,
-- без записи в БД). Соседнюю listings.canonical НЕ трогаем — она вырождена (t у
-- всех 142 569), но её читает WHERE listings_search_mv (050/094), и снос колонки
-- потянул бы пересоздание matview с шестью индексами ради нулевого выигрыша.
--
-- E. house_sources.raw_payload + GIN-индекс по нему — 46 813 строк, NULL у всех.
-- Оба писателя house_sources (matching/houses.py:556, house_dedup_merge.py:493)
-- эту колонку в INSERT не включают; читателей нет, из публичного контракта
-- market.v_house_sources (154) она намеренно исключена. GIN-индекс по колонке,
-- которая всегда NULL, — чистая стоимость на каждой вставке.
-- Соседний house_sources.ext_url тоже пуст 46 813/46 813, но он ВХОДИТ в
-- market.v_house_sources — удаление сломало бы обещание стабильности контракта.
-- Оставляем и подписываем (см. п. 3).
--
-- F. v_data_quality.price_disagreements_count — показатель, который не может быть
-- ненулевым. Считает строки v_price_divergence («разброс цен между площадками у
-- одного объявления > 5%»), а на проде у 89 699 объявлений РОВНО ОДИН источник
-- каждое: distinct listing_id = 89 699 при 89 699 строках listing_sources.
-- v_price_divergence = 0 строк, v_cross_source_health = 0 строк. Причина не в
-- данных: боевой путь загрузки (scrapers/base.py::_link_listing_to_house) зовёт
-- upsert_listing_source('source_link') напрямую и НЕ зовёт match_or_create_listing —
-- см. NOTE на matching/listings.py:188. Пока связывание источников не подключено,
-- ноль здесь читается как «расхождений нет», хотя честно это «мы не сравниваем».
-- Тот же довод, по которому 214 убрала outliers_flagged.
--
-- ── 3. ОСТАВЛЯЕМ И ПОДПИСЫВАЕМ ──────────────────────────────────────────────
--
-- G. Сами v_price_divergence / v_cross_source_health не удаляем: они выразимы и
-- станут ненулевыми в тот день, когда связывание источников заработает. Но в
-- COMMENT должно быть написано, что они пусты СТРУКТУРНО, а не по счастью.
-- Туда же — house_sources.ext_url.
--
-- ⚠️ View-зависимость (тот же порядок, что 214): v_data_quality содержит
-- `WITH active_listings AS (SELECT * FROM listings)`, что фиксирует column-level
-- зависимость на ВСЕ колонки listings. Порядок: DROP VIEW → DROP COLUMN →
-- CREATE VIEW. Последний DDL v_data_quality — 214_drop_dead_run_metrics.sql.
--
-- Dependencies: 028_matching_tables.sql, 029_extend_matching_valuation_dynamics.sql,
-- 046_views.sql, 052_scrape_schedules.sql, 098_asking_to_sold_ratios_tiered.sql,
-- 176_domrf_kapremont.sql, 214_drop_dead_run_metrics.sql.
-- Deploy order: применять ПОСЛЕ деплоя backend-кода, регистрирующего
-- 'domrf_kapremont_load' в product_handlers.build_product_handlers() — иначе
-- kit-scheduler не найдёт Handler на первом due-run. next_run_at = завтра, так что
-- даже при обратном порядке накатки окно не наступит раньше следующих суток.
-- Идемпотентно: ON CONFLICT DO NOTHING / DROP ... IF EXISTS / CREATE OR REPLACE VIEW /
-- бэкфилл под WHERE filters_hash IS NULL.
BEGIN;
-- ── A. Расписание загрузчика ДОМ.РФ (оборванная проводка) ────────────────────
-- enabled = true: внешний источник, но открытые данные без auth и без анти-бота
-- (тот же класс, что sber_index_pull / rosreestr_quarter_poll).
-- interval_days = 7: реестр капремонта не меняется ежедневно, а прогон качает два
-- zip и парсит ~30 тыс. строк. Недельный такт достаточен и не жжёт трафик впустую.
-- Ключ читает compute_next_run_at из default_params (см. 129).
-- Окно 03:00-04:00 UTC — до rosreestr_dkp_import (04:00-06:00) и до
-- asking_to_sold_ratio_refresh (06:00-07:00): год постройки должен доехать в
-- listings раньше, чем по ним считают дневные агрегаты.
INSERT INTO scrape_schedules (
source,
enabled,
window_start_hour,
window_end_hour,
next_run_at,
default_params
)
VALUES
(
'domrf_kapremont_load',
true,
3,
4,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 3)) AT TIME ZONE 'UTC',
'{"interval_days": 7}'::jsonb
)
ON CONFLICT (source) DO NOTHING;
-- ── B. Бэкфилл filters_hash из уже сохранённых сырых ответов ─────────────────
-- Путь тот же, что теперь читает парсер. Пустую строку не пишем (NULLIF) — «есть
-- ключ, но он пуст» и «ключа нет» должны остаться одинаково NULL, а не разойтись.
UPDATE external_valuations
SET filters_hash = NULLIF(raw_payload #>> '{estimation,sale,filtersHash}', '')
WHERE filters_hash IS NULL
AND raw_payload #>> '{estimation,sale,filtersHash}' IS NOT NULL;
-- ── C. Тиерные коэффициенты выкупа: две таблицы без читателя и писателя ──────
DROP TABLE IF EXISTS asking_to_sold_ratios_tiered;
DROP TABLE IF EXISTS asking_to_sold_tier_bounds;
-- ── D+E+F. Колонки без писателя + показатель, который не может быть ненулевым ─
DROP VIEW IF EXISTS v_data_quality;
ALTER TABLE IF EXISTS listings DROP COLUMN IF EXISTS merged_into;
DROP INDEX IF EXISTS house_sources_raw_payload_gin_idx;
ALTER TABLE IF EXISTS house_sources DROP COLUMN IF EXISTS raw_payload;
-- DDL идентичен 214, минус строка price_disagreements_count (см. п. F шапки).
CREATE OR REPLACE VIEW v_data_quality AS
WITH active_listings AS (
SELECT * FROM listings WHERE is_active = true
)
SELECT
(SELECT count(*) FROM houses) AS houses_total,
(SELECT count(*) FROM houses h
WHERE EXISTS (SELECT 1 FROM house_sources hs WHERE hs.house_id = h.id)) AS houses_with_source,
(SELECT count(*) FROM houses h
WHERE EXISTS (SELECT 1 FROM house_sources hs
WHERE hs.house_id = h.id AND hs.ext_source = 'avito')) AS houses_with_avito,
(SELECT count(*) FROM houses h
WHERE EXISTS (SELECT 1 FROM house_sources hs
WHERE hs.house_id = h.id AND hs.ext_source LIKE 'cian%')) AS houses_with_cian,
(SELECT count(*) FROM houses h
WHERE EXISTS (SELECT 1 FROM house_sources hs
WHERE hs.house_id = h.id AND hs.ext_source = 'yandex')) AS houses_with_yandex,
(SELECT count(*) FROM (
SELECT house_id FROM house_sources GROUP BY house_id HAVING count(*) >= 2
) sub) AS houses_2plus_sources,
(SELECT count(*) FROM (
SELECT house_id FROM house_sources GROUP BY house_id HAVING count(*) >= 3
) sub) AS houses_3plus_sources,
(SELECT count(*) FROM active_listings) AS listings_active,
(SELECT count(*) FROM (
SELECT listing_id FROM listing_sources
WHERE listing_id IN (SELECT id FROM active_listings)
GROUP BY listing_id HAVING count(*) >= 2
) sub) AS listings_dedup_2sources,
(SELECT count(*) FROM active_listings WHERE lat IS NOT NULL) * 100.0
/ NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_geocoded,
(SELECT count(*) FROM active_listings WHERE cadastral_number IS NOT NULL) * 100.0
/ NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_cadastr,
(SELECT count(*) FROM active_listings WHERE description IS NOT NULL) * 100.0
/ NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_description,
(SELECT count(*) FROM active_listings l
JOIN houses h ON h.id = l.house_id_fk
WHERE h.year_built IS NOT NULL) * 100.0
/ NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_year_built,
NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'avito') AS avito_last_scrape_ago,
NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'cian') AS cian_last_scrape_ago,
NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'yandex') AS yandex_last_scrape_ago;
COMMENT ON VIEW v_data_quality IS
'KPI-снимок для РУЧНЫХ psql-запросов. Читателей в коде нет (проверено #2674): '
'/api/v1/admin/scraper/data-quality считает свои метрики сам и этот view не трогает. '
'#2674: price_disagreements_count убран — у всех 89 699 объявлений ровно один '
'источник, поэтому показатель структурно не мог быть ненулевым и ноль читался как '
'«расхождений нет» вместо «мы не сравниваем». listings_dedup_2sources оставлен '
'намеренно: он ту же пустоту называет своим именем («объявлений с 2+ источниками»), '
'ноль в нём — честный ответ, а не мнимое благополучие.';
-- ── G. Подписи к тому, что оставлено как задел ───────────────────────────────
COMMENT ON VIEW v_price_divergence IS
'Объявления с разбросом цен между источниками > 5%. #2674: СЕГОДНЯ ВСЕГДА ПУСТ '
'и это структурно, а не случайно — боевой путь загрузки зовёт '
'upsert_listing_source(''source_link'') напрямую (scrapers/base.py::_link_listing_to_house) '
'и не зовёт match_or_create_listing, поэтому у каждого объявления ровно один '
'источник (89 699 строк listing_sources = 89 699 разных listing_id). View оставлен '
'как задел: станет осмысленным в тот день, когда связывание источников подключат '
'(см. NOTE на matching/listings.py:188). Из v_data_quality исключён — там ноль '
'выглядел как результат проверки.';
COMMENT ON VIEW v_cross_source_health IS
'Пер-объявленческая агрегация цен по источникам. #2674: пуст по той же причине, '
'что v_price_divergence — HAVING count(*) >= 2 недостижим, пока связывание '
'источников не подключено. Читателей в коде нет.';
COMMENT ON COLUMN house_sources.ext_url IS
'#2674: NULL у всех 46 813 строк — ни один из двух писателей house_sources '
'(matching/houses.py, house_dedup_merge.py) эту колонку не заполняет. НЕ удалена '
'только потому, что входит в публичный контракт market.v_house_sources (мигр. 154), '
'где удаление колонки объявлено ломающим изменением. Соседний raw_payload из '
'контракта исключён и удалён этой же миграцией.';
COMMENT ON COLUMN houses.has_panorama IS
'#2674: заполняется из yandex_valuation (estimator._save_yandex_house_panorama). '
'До этой правки колонка была пуста у всех 9 366 домов, хотя парсер флаг разбирал. '
'Пишется ТОЛЬКО когда страница оценки подтверждённо отрисовалась (в мете есть год '
'или этажность) — иначе «метки нет» неотличимо от «страница не открылась», и NULL '
'честнее false.';
COMMENT ON COLUMN external_valuations.filters_hash IS
'sha256 фильтров от самого Циана, estimation.sale.filtersHash (#2674: НЕ '
'estimation.sale.data.filtersHash — из-за лишнего уровня колонка была пуста 0/1658). '
'Отличается от cache_key: cache_key — наш хеш параметров ЗАПРОСА, filters_hash — '
'хеш того, во что Циан их разрешил, поэтому он способен схлопнуть варианты записи '
'одного адреса, которые cache_key разводит.';
COMMIT;