diff --git a/tradein-mvp/backend/data/sql/300_sales_vs_listings_deals_rooms_drop.sql b/tradein-mvp/backend/data/sql/300_sales_vs_listings_deals_rooms_drop.sql new file mode 100644 index 00000000..72f2109b --- /dev/null +++ b/tradein-mvp/backend/data/sql/300_sales_vs_listings_deals_rooms_drop.sql @@ -0,0 +1,219 @@ +-- 300_sales_vs_listings_deals_rooms_drop.sql +-- Purpose: #3451 — TVF street_sales_vs_listings() фильтровала сделки по `d.rooms`, +-- а `deals.rooms` у источника 'rosreestr' — НЕ комнатность, а бакет площади. +-- Импортёр (tradein-mvp/deploy/import-rosreestr.sh) пишет туда +-- `CASE WHEN area < 30 THEN 0 WHEN area < 44 THEN 1 WHEN area < 62 THEN 2 +-- WHEN area < 85 THEN 3 ELSE 4 END` +-- — прод: 321 559 строк из 321 560 удовлетворяют `rooms == area_bucket(area_m2)`, +-- max(rooms) = 4 (пятикомнатных в данных не бывает по построению). То есть предикат +-- работал ВТОРЫМ фильтром по площади и спорил с полосой ±tolerance, которую функция +-- считает сама: клиент 49 м² / 1к получал полосу 41.7–56.4 м², но `d.rooms = 1` +-- оставлял из неё только < 44 м². +-- +-- Ровно эта патология снята в #3256 (PR #3445) на четырёх сделочных площадках +-- эстиматора; здесь — последний оставшийся потребитель. +-- +-- КЛЮЧ АСИММЕТРИЧНЫЙ, копипастой из #3256 не чинится: +-- - `d.rooms = p_rooms` в window_deals — СНЯТ (синтетика из площади); +-- - `l.rooms = p_rooms` в window_listings — ОСТАЁТСЯ: у объявлений комнатность +-- настоящая (приходит с карточки), и это единственный признак ассортимента +-- на листинговой стороне. +-- +-- Прод-замер (2026-09-12, БД tradein, 1160 реальных клиентских запросов из +-- trade_in_estimates; улица извлеклась у 954, у 206 — известная H1 «адрес вне +-- словаря», к этой правке отношения не имеет). Считалось тем же путём, что у +-- продукта: street/city резолвятся extract_street_name()/_resolve_target_city(): +-- - непустой ответ /sales-vs-listings: 805 (84.4 %) → 899 (94.2 %), впервые +-- непустых 94 клиента; +-- - сделок в выборке суммарно: 68 147 → 88 816; +-- - из них с подобранным объявлением (то, что реально показывается парами): +-- 30 830 → 39 193; +-- - выборка не сократилась НИ У КОГО (0 из 954) — предикат умел только резать. +-- Прогноз из #3451 был «те же 180 клиентов»; измеренная величина — 94. Разница в +-- том, что оценка 180 бралась по коридору эстиматора (другие period/tolerance и +-- другой street-pattern), а не по этой витрине; в файл кладётся измеренное. +-- +-- NULL здесь не появляется: ветка ELSE в CASE импортёра ловит и NULL-площадь +-- (все WHEN дают NULL → ELSE 4), прод подтверждает 0 NULL в deals.rooms при +-- source='rosreestr'. Поэтому `deal_rooms: int` в SalesListingPair остаётся +-- обязательным полем — контракт API не меняется. +-- +-- Что НЕ меняется и почему: +-- - Сигнатура функции — те же 7 аргументов и те же типы, что после м.205/211. +-- CREATE OR REPLACE с ИЗМЕНЁННЫМ списком типов создал бы ВТОРУЮ перегрузку +-- вместо замены (грабли #2627) — здесь список побайтово тот же. +-- - `is_active` по-прежнему НЕ фильтруется (снятые объявления и есть материал +-- пейринга — объявление снимают ПОСЛЕ продажи), см. шапки 067/205/211. +-- - Сегментный гард #2660/#1186 и city-предикаты #2583 H4 перенесены дословно. +-- - Caller (app/api/v1/trade_in.py, /sales-vs-listings) не меняется. +-- +-- ЗАВИСИМОСТИ: 211 (текущее тело + 7-арг сигнатура). Deploy order: после 299. +-- Идемпотентность: CREATE OR REPLACE + COMMENT ON — re-run safe. + +BEGIN; + +CREATE OR REPLACE FUNCTION street_sales_vs_listings( + p_street_pattern text, + p_area_m2 numeric, + p_rooms integer, + p_window_days integer DEFAULT 180, + p_area_tolerance numeric DEFAULT 0.15, + p_period_months integer DEFAULT 24, + p_target_city text DEFAULT NULL +) +RETURNS TABLE ( + deal_id bigint, + deal_date date, + deal_price_rub bigint, + deal_price_per_m2 integer, + deal_area_m2 numeric, + deal_rooms integer, + deal_floor integer, + deal_address text, + listing_id bigint, + listing_source text, + listing_source_url text, + listing_date date, + listing_price_rub bigint, + listing_price_per_m2 integer, + listing_area_m2 numeric, + days_listing_to_deal integer, + discount_pct numeric +) +LANGUAGE sql +STABLE +AS $$ + WITH window_deals AS ( + -- Сделки в улице + период. Фильтр по area + (#2583 H4) city. + -- Предиката по d.rooms здесь НЕТ — #3256/#3451: deals.rooms у источника + -- 'rosreestr' не комнатность, а бакет площади (см. шапку файла). + SELECT + d.id AS deal_id, + d.deal_date AS deal_date, + d.price_rub AS deal_price_rub, + d.price_per_m2 AS deal_price_per_m2, + d.area_m2 AS deal_area_m2, + d.rooms AS deal_rooms, + d.floor AS deal_floor, + d.address AS deal_address + FROM deals d + WHERE d.source = 'rosreestr' + AND d.address ILIKE p_street_pattern + AND d.area_m2 BETWEEN p_area_m2 * (1.0 - p_area_tolerance) + AND p_area_m2 * (1.0 + p_area_tolerance) + AND d.deal_date > NOW() - (p_period_months || ' months')::interval + AND d.price_rub > 0 + -- #2583 H4: deals.city заполнена на 100% — строгое равенство. + -- NULL p_target_city (город вне словаря) → фильтр не применяется. + AND (p_target_city IS NULL OR LOWER(d.city) = LOWER(p_target_city)) + ), + window_listings AS ( + -- Кандидаты-listings на той же улице, rooms exact (у ОБЪЯВЛЕНИЙ комнатность + -- настоящая — предикат законен и остаётся, #3451), area ±tolerance, + -- (#2583 H4) тот же город что deals-сторона, (#2660) только вторичка. + SELECT + l.id AS listing_id, + l.source AS listing_source, + l.source_url AS listing_source_url, + l.listing_date AS listing_date, + l.price_rub AS listing_price_rub, + l.price_per_m2 AS listing_price_per_m2, + l.area_m2 AS listing_area_m2, + l.rooms AS listing_rooms, + COALESCE(l.listing_date, l.scraped_at::date) AS listing_event_date + FROM listings l + WHERE l.address ILIKE p_street_pattern + AND l.rooms = p_rooms + AND l.area_m2 BETWEEN p_area_m2 * (1.0 - p_area_tolerance) + AND p_area_m2 * (1.0 + p_area_tolerance) + AND l.price_rub > 0 + AND COALESCE(l.listing_date, l.scraped_at::date) + > NOW() - ((p_period_months + 6) || ' months')::interval + -- #2583 H4: listings.city заполнена ЧАСТИЧНО (прод: avito 63%, + -- yandex 19%, cian 4.6%, domklik 0.6%, n1 0%) — NULL считается "своим" + -- (симметрично asking_to_sold_ratio.py #2583 H2), иначе строгий + -- фильтр выбросил бы почти все listings кроме avito. + AND (p_target_city IS NULL OR l.city IS NULL OR LOWER(l.city) = LOWER(p_target_city)) + -- #2660 novostroyki guard (#1186): к ДКП-сделке вторички нельзя + -- подставлять лот застройщика — девелоперский прайс не торгуется и + -- уводит показываемый «медианный торг». Прод: 27.3% кандидатов — + -- первичка. NULL = legacy вторичка до м.011, оставляем. + AND (l.listing_segment IS NULL OR l.listing_segment = 'vtorichka') + ), + paired AS ( + -- LEFT JOIN: сохраняем все сделки даже если нет listing match. + -- Для каждой сделки выбираем listing с listing_date ближайший + -- к deal_date (предпочтительно перед сделкой). + SELECT DISTINCT ON (wd.deal_id) + wd.deal_id, + wd.deal_date, + wd.deal_price_rub, + wd.deal_price_per_m2, + wd.deal_area_m2, + wd.deal_rooms, + wd.deal_floor, + wd.deal_address, + wl.listing_id, + wl.listing_source, + wl.listing_source_url, + wl.listing_date, + wl.listing_price_rub, + wl.listing_price_per_m2, + wl.listing_area_m2, + (wd.deal_date - wl.listing_event_date)::integer AS days_listing_to_deal, + CASE + WHEN wl.listing_price_rub IS NOT NULL AND wl.listing_price_rub > 0 + THEN ROUND( + (wd.deal_price_rub - wl.listing_price_rub)::numeric + / wl.listing_price_rub * 100, + 2 + ) + ELSE NULL + END AS discount_pct + FROM window_deals wd + LEFT JOIN window_listings wl + ON wl.listing_event_date + BETWEEN (wd.deal_date - (p_window_days || ' days')::interval)::date + AND (wd.deal_date + interval '30 days')::date + ORDER BY + wd.deal_id, + -- prefer listing event дата перед сделкой и ближе к ней + CASE WHEN wl.listing_event_date IS NULL THEN 1 ELSE 0 END, + CASE WHEN wl.listing_event_date <= wd.deal_date THEN 0 ELSE 1 END, + ABS((wd.deal_date - wl.listing_event_date)) + ) + SELECT + deal_id, + deal_date, + deal_price_rub, + deal_price_per_m2, + deal_area_m2, + deal_rooms, + deal_floor, + deal_address, + listing_id, + listing_source, + listing_source_url, + listing_date, + listing_price_rub, + listing_price_per_m2, + listing_area_m2, + days_listing_to_deal, + discount_pct + FROM paired + ORDER BY deal_date DESC; +$$; + +COMMENT ON FUNCTION street_sales_vs_listings(text, numeric, integer, integer, numeric, integer, text) IS + 'Pairs (ДКП-сделка, listing) для улицы. PR K / issue #564 Foundation Phase 1, ' + 'city-filter #2583 H4 (миграция 205), segment-guard #2660/#1186 (миграция 211), ' + 'снятие предиката по d.rooms #3451/#3256 (миграция 300). ' + 'Per-street matching: address ILIKE, area ±tolerance, rooms exact ТОЛЬКО на ' + 'listings-стороне (deals.rooms у rosreestr — синтетика из площади), window_days ' + 'до даты сделки (+30д grace), city-scope (p_target_city, deals строго / listings ' + 'терпимо к NULL), listings — только вторичка (listing_segment IS NULL или ' + 'vtorichka). Возвращает LEFT JOIN — сделки без listing match имеют ' + 'listing_* = NULL. discount_pct = (deal - listing) / listing * 100. ' + 'is_active намеренно НЕ фильтруется: снятые объявления и есть материал пейринга.'; + +COMMIT; diff --git a/tradein-mvp/backend/tests/test_migration_300_sales_vs_listings_deals_rooms.py b/tradein-mvp/backend/tests/test_migration_300_sales_vs_listings_deals_rooms.py new file mode 100644 index 00000000..8b7b7f47 --- /dev/null +++ b/tradein-mvp/backend/tests/test_migration_300_sales_vs_listings_deals_rooms.py @@ -0,0 +1,140 @@ +"""Static guards для миграции 300 (#3451: снят предикат `d.rooms` в street_sales_vs_listings()). + +Прод применяет data/sql построчно строго (ON_ERROR_STOP); полный DB-прогон требует живой +БД — здесь фиксируем структурные инварианты, которые ломаются копипастой: + + - предиката по `d.rooms` в теле НЕТ: `deals.rooms` у источника 'rosreestr' — бакет + площади (импортёр, `deploy/import-rosreestr.sh`), а не комнатность, и предикат + работал вторым фильтром по площади поверх полосы ±tolerance (#3256); + - предикат по `l.rooms` ОСТАЛСЯ: ключ асимметричный, у объявлений комнатность + настоящая. «Починить симметрично» — значит потерять единственный признак + ассортимента на листинговой стороне; + - сигнатура и RETURNS TABLE не изменились относительно 211 — иначе CREATE OR REPLACE + создаст ВТОРУЮ перегрузку вместо замены (грабли #2627), и caller получит старое тело; + - гард сегмента #2660/#1186 и city-предикаты #2583 H4 перенесены дословно, не потеряны; + - транзакционность + идемпотентность (CREATE OR REPLACE, без DROP/TRUNCATE). +""" + +from __future__ import annotations + +import re +from pathlib import Path + +_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" +_MIGRATION_211 = _SQL_DIR / "211_sales_vs_listings_segment_guard.sql" +_MIGRATION_300 = _SQL_DIR / "300_sales_vs_listings_deals_rooms_drop.sql" + +_SIGNATURE_TYPES = ("text", "numeric", "integer", "integer", "numeric", "integer", "text") +_SEGMENT_GUARD = "(l.listing_segment is null or l.listing_segment = 'vtorichka')" + + +def _sql(path: Path = _MIGRATION_300) -> str: + return path.read_text(encoding="utf-8") + + +def _flat(text: str) -> str: + return re.sub(r"\s+", " ", text).strip().lower() + + +def _function_body(path: Path = _MIGRATION_300) -> str: + """Тело функции между `AS $$` и `$$;`, без `--`-комментариев, нормализованное. + + COMMENT ON FUNCTION — тоже исполняемый SQL, и его текст описывает фильтры словами + (в т.ч. «rooms exact ТОЛЬКО на listings-стороне»). Проверки «такого предиката в + запросе нет» обязаны смотреть только на тело, иначе краснеют от собственной цитаты. + """ + sql = _sql(path) + start = sql.index("AS $$") + end = sql.index("$$;", start) + body = "\n".join( + line.split("--", 1)[0] for line in sql[start:end].splitlines() if line.split("--", 1)[0] + ) + return _flat(body) + + +def _code_only(path: Path) -> str: + """SQL без `--`-комментариев. + + Шапки 211 и 300 разные и сами упоминают «RETURNS TABLE» — искать границы блоков + по сырому тексту значит сравнивать комментарий с кодом (ровно так этот тест и + покраснел в первый раз). + """ + return "\n".join( + line.split("--", 1)[0] for line in _sql(path).splitlines() if line.split("--", 1)[0] + ) + + +def _function_signature(path: Path) -> str: + sql = _code_only(path) + start = sql.index("CREATE OR REPLACE FUNCTION street_sales_vs_listings(") + end = sql.index("RETURNS TABLE", start) + return _flat(sql[start:end]) + + +def _returns_table(path: Path) -> str: + sql = _code_only(path) + start = sql.index("RETURNS TABLE") + end = sql.index("LANGUAGE sql", start) + return _flat(sql[start:end]) + + +def test_migration_300_exists() -> None: + assert _MIGRATION_300.exists(), f"missing migration: {_MIGRATION_300}" + + +def test_migration_300_is_transactional_and_idempotent() -> None: + sql = _sql() + assert "BEGIN;" in sql + assert "COMMIT;" in sql + assert "CREATE OR REPLACE FUNCTION street_sales_vs_listings(" in sql + flat = _flat(sql) + assert "drop function" not in flat + assert "truncate" not in flat + # ровно одна замена — иначе вторая перезапишет первую молча + assert flat.count("create or replace function street_sales_vs_listings") == 1 + + +def test_deals_side_has_no_rooms_predicate() -> None: + """Суть #3451: сделочная сторона больше не ключуется синтетическим rooms.""" + body = _function_body() + assert "d.rooms = p_rooms" not in body + assert "d.rooms=p_rooms" not in body + + +def test_listings_side_keeps_rooms_predicate() -> None: + """Асимметрия ключа: у объявлений комнатность настоящая, предикат законен.""" + body = _function_body() + assert "l.rooms = p_rooms" in body + + +def test_deal_rooms_still_returned() -> None: + """Колонку не убирали: endpoint читает `deal_rooms` по имени, контракт API тот же.""" + assert "d.rooms as deal_rooms" in _function_body() + assert "deal_rooms" in _returns_table(_MIGRATION_300) + + +def test_signature_identical_to_211() -> None: + """Иначе CREATE OR REPLACE создаст перегрузку, а caller продолжит звать старое тело.""" + assert _function_signature(_MIGRATION_300) == _function_signature(_MIGRATION_211) + sig = _function_signature(_MIGRATION_300) + for t in _SIGNATURE_TYPES: + assert t in sig + + +def test_returns_table_identical_to_211() -> None: + assert _returns_table(_MIGRATION_300) == _returns_table(_MIGRATION_211) + + +def test_segment_and_city_guards_survived() -> None: + """Перенос тела не должен ронять чужие правки (#2660/#1186 и #2583 H4).""" + body = _function_body() + assert _SEGMENT_GUARD in body + assert "lower(d.city) = lower(p_target_city)" in body + assert "lower(l.city) = lower(p_target_city)" in body + + +def test_only_the_rooms_predicate_differs_from_211() -> None: + """Тело 300 = тело 211 минус ровно один предикат — ничего лишнего не уехало.""" + body_211 = _function_body(_MIGRATION_211) + body_300 = _function_body(_MIGRATION_300) + assert body_211.replace("and d.rooms = p_rooms ", "") == body_300 diff --git a/tradein-mvp/deploy/import-rosreestr.sh b/tradein-mvp/deploy/import-rosreestr.sh index 303326ed..1e2430d1 100755 --- a/tradein-mvp/deploy/import-rosreestr.sh +++ b/tradein-mvp/deploy/import-rosreestr.sh @@ -76,10 +76,10 @@ docker exec "$SRC_PG" psql -U "$SRC_USER" -d "$SRC_DB" -v ON_ERROR_STOP=on -c " -- НЕ фильтруют по deals.rooms ИМЕННО потому, что здесь синтетика; с -- настоящей комнатностью предикат нужно вернуть, иначе все сайты молча -- продолжат ключеваться площадью. - -- ОСТАВШИЙСЯ потребитель, который ВСЁ ЕЩЁ фильтрует по d.rooms: - -- TVF street_sales_vs_listings (data/sql/211_sales_vs_listings_segment_guard.sql:89), - -- её зовёт API /sales-vs-listings (trade_in.py). Там ключ асимметричный — - -- d.rooms синтетика, l.rooms настоящая, — копипастой из #3256 не чинится. + -- Потребителей, фильтрующих по d.rooms, БОЛЬШЕ НЕТ: последний — + -- TVF street_sales_vs_listings — расчищен миграцией 300 (#3451). Ключ там + -- асимметричный: d.rooms снят, l.rooms ОСТАВЛЕН (у объявлений комнатность + -- настоящая). Появится новый потребитель — сверься с этим якорем. CASE WHEN area < 30 THEN 0 WHEN area < 44 THEN 1 WHEN area < 62 THEN 2 WHEN area < 85 THEN 3