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
Восемь находок «написано, покрыто тестами, ни разу не сработало» разведены на три разных диагноза. Две из восьми оказались не мёртвым кодом, а оборванной проводкой. ПОДКЛЮЧЕНО Загрузчик ДОМ.РФ. 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
232 lines
18 KiB
PL/PgSQL
232 lines
18 KiB
PL/PgSQL
-- 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;
|