diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index 13f4213a..853d9a1d 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -1831,7 +1831,7 @@ def get_sales_vs_listings( Per-street view: Росреестр open dataset агрегирует адреса до улицы. """ - from app.services.estimator import _percentile, extract_street_name + from app.services.estimator import _percentile, _resolve_target_city, extract_street_name def _empty(reason_street: str | None = None) -> SalesVsListingsResponse: return SalesVsListingsResponse( @@ -1852,6 +1852,15 @@ def get_sales_vs_listings( logger.warning("sales-vs-listings: cannot extract street from %r", address) return _empty() + # #2583 H4 city-scope (зеркало /street-deals #C1, trade_in.py:1717): без него + # street_pattern матчит одноимённые улицы ЛЮБОГО города обл.66 на ОБЕИХ сторонах + # JOIN (deals.address / listings.address хранят "<Город>, <Улица>") — прод-аудит + # показал 49% явно чужого города + 50% NULL-city listings для проверенных стритов, + # медианный discount_pct уезжал в -60%+ на смеси рынков. target_city резолвится тем + # же словарём (~30 городов обл.66), что и street-deals; None (адрес вне словаря, + # известная H1) → фильтр не применяется на TVF-стороне (см. миграцию 205). + target_city = _resolve_target_city(address) + rows = ( db.execute( text( @@ -1868,7 +1877,8 @@ def get_sales_vs_listings( CAST(:rooms AS integer), CAST(:window_days AS integer), CAST(:area_tolerance AS numeric), - CAST(:period_months AS integer) + CAST(:period_months AS integer), + CAST(:target_city AS text) ) """ ), @@ -1879,6 +1889,7 @@ def get_sales_vs_listings( "window_days": window_days, "area_tolerance": area_tolerance, "period_months": period_months, + "target_city": target_city, }, ) .mappings() diff --git a/tradein-mvp/backend/data/sql/205_sales_vs_listings_city_filter.sql b/tradein-mvp/backend/data/sql/205_sales_vs_listings_city_filter.sql new file mode 100644 index 00000000..116a4a02 --- /dev/null +++ b/tradein-mvp/backend/data/sql/205_sales_vs_listings_city_filter.sql @@ -0,0 +1,222 @@ +-- 205_sales_vs_listings_city_filter.sql +-- Purpose: #2583 H4 — street_sales_vs_listings() (067) строит пары «ДКП-сделка ↔ +-- listing» через LEFT JOIN, где условие матчинга — ТОЛЬКО street_pattern (ILIKE) + +-- rooms + area ±tolerance + дата. Городской корреляции нет вообще: deals.address / +-- listings.address хранят "<Город>, <Улица>" (Росреестр агрегирует до улицы, без +-- дома), а street_pattern = голое имя улицы («Ленина», «Красноармейская», +-- «Советская» — десятки одноимённых улиц в разных городах обл.66). ILIKE +-- '%Ленина%' матчит "Нижний Тагил, Ленина" И "Екатеринбург, Ленина" одинаково — +-- пара выбирается ближайшей по дате, город игнорируется. +-- +-- Прод-репро (см. PR-описание): street='Ленина', rooms=2, area≈44.3м², defaults — +-- 352 total pairs по всем городам, 244 с listing-match, из них 119 (49%) явно +-- чужого города (deal.city <> listing.city, обе стороны известны) + 122 (50%) с +-- listing.city IS NULL (Циан/Домклик/Яндекс, город неизвестен — потенциально тоже +-- чужой). Для Нижнего Тагила конкретно: 8 сделок получили match, 4 — явно чужой +-- город (ЕКБ и др.). median_discount_pct на смеси городов уезжает в -63.6% +-- (в audit-заходе см. #2583 -59%) — «медианный торг» на витрине читается как +-- реальная рыночная скидка по улице пользователя, а на деле мешает рынки разной +-- ценовой полки. +-- +-- Соседний эндпоинт /street-deals (trade_in.py:1654) городской скоуп уже получил +-- (комментарий #C1 там же) — тот же паттерн переносим сюда: город резолвится +-- ОДИН раз в Python через _resolve_target_city(address) (estimator.py:1350, +-- словарь ~30 городов обл.66 вкл. ЕКБ + sweep-города) и передаётся как ОДИН +-- bind-параметр в TVF, который применяет его к ОБЕИМ сторонам JOIN: +-- - deals.city заполнена на 100% (проверено на проде) → строгое равенство +-- LOWER(d.city) = LOWER(p_target_city). +-- - listings.city заполнена ЧАСТИЧНО (прод-замер: avito 63%, yandex 19%, +-- cian 4.6%, domklik 0.6%, n1 0%) → предикат терпим к NULL, симметрично +-- паттерну asking_to_sold_ratio.py (#2583 H2, PR #2617): +-- (l.city IS NULL OR LOWER(l.city) = LOWER(p_target_city)). +-- Строгий `l.city = p_target_city` без IS NULL выбросил бы ~80-95% listings +-- для источников кроме avito — по мере роста покрытия колонки предикат сам +-- ужесточается без правок кода. +-- - p_target_city IS NULL (адрес вне словаря SVERDLOVSK_OBLAST_CITIES, редкий +-- мелкий н.п. области — тот же неполный список, что в известной находке H1) +-- → фильтр не применяется НИ на одной стороне, текущее (pre-fix) поведение +-- сохраняется как fallback. Осознанно, не побочный эффект: /street-deals уже +-- принял этот компромисс для того же словаря городов — расхождение в +-- поведении между двумя виджетами на одной странице (для одного и того же +-- адреса) было бы хуже, чем редкий edge-case без фильтра. H1 — известная +-- отдельная находка (fix отдельным PR), здесь её не трогаем. +-- +-- Signature change: p_target_city добавлен СЕДЬМЫМ параметром с DEFAULT NULL — +-- обратная совместимость с любым caller'ом, который вызывает функцию 6 +-- позиционными аргументами (сейчас единственный caller — trade_in.py:1865, +-- обновляется в этом же PR). CREATE OR REPLACE FUNCTION с ДОБАВЛЕННЫМ параметром +-- технически создаёт НОВУЮ перегрузку (Postgres матчит функции по списку типов +-- аргументов) — поэтому старую 6-параметровую сигнатуру дропаем явно ПЕРЕД +-- CREATE OR REPLACE, чтобы не остались висеть два оверлоада одной функции. +-- DROP FUNCTION IF EXISTS с 6-арг сигнатурой идемпотентен: при повторном +-- прогоне (когда функция уже 7-арг) просто no-op, ошибки не будет. +-- +-- Grep-проверка вызывающих (2026-08): единственный caller — +-- app/api/v1/trade_in.py:1865 (/sales-vs-listings). Convenience view +-- v_street_sales_vs_listings из 067 уже дропнута в 068 (была без street-match, +-- генерила 50k spurious pairs) — фиксить нечего, объекта не существует. +-- +-- Deploy order: после 204. Второй caller (Python) обновляется в том же PR — +-- миграция должна применяться ДО деплоя backend-кода (стандартный SQL-first +-- порядок), но т.к. новый параметр DEFAULT NULL — старый код (без city) продолжит +-- работать без ошибок между миграцией и деплоем кода (не критичный порядок, но +-- соблюдаем канон). + +BEGIN; + +DROP FUNCTION IF EXISTS street_sales_vs_listings(text, numeric, integer, integer, numeric, integer); + +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 ( + -- Сделки в улице + период. Фильтр по rooms + area + (#2583 H4) city. + 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.rooms = p_rooms + 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, area ±tolerance, + -- (#2583 H4) тот же город что deals-сторона. + 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)) + ), + 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). Per-street matching: address ILIKE, area ' + '±tolerance, rooms exact, window_days до даты сделки (+30д grace), city-scope ' + '(p_target_city, deals строго / listings терпимо к NULL). Возвращает LEFT ' + 'JOIN — сделки без listing match имеют listing_* = NULL. discount_pct = ' + '(deal - listing) / listing * 100.'; + +COMMIT; diff --git a/tradein-mvp/backend/tests/test_migration_205_sales_vs_listings_city_filter.py b/tradein-mvp/backend/tests/test_migration_205_sales_vs_listings_city_filter.py new file mode 100644 index 00000000..e5e27b2f --- /dev/null +++ b/tradein-mvp/backend/tests/test_migration_205_sales_vs_listings_city_filter.py @@ -0,0 +1,159 @@ +"""Static guards for migration 205 (city-scope в street_sales_vs_listings(), #2583 H4). + +Прод применяет data/sql построчно строго (ON_ERROR_STOP). Полный DB-прогон требует +живой БД; здесь фиксируем структурные инварианты: транзакционность, идемпотентность +DROP FUNCTION (старая 6-арг сигнатура), наличие НОВОЙ 7-арг сигнатуры с +p_target_city DEFAULT NULL, city-предикаты на ОБЕИХ сторонах JOIN (deals строго, +listings терпимо к NULL — зеркало asking_to_sold_ratio.py #2583 H2), отсутствие +psycopg CAST-ловушки, отсутствие DROP TABLE/TRUNCATE. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" +_MIGRATION_205 = _SQL_DIR / "205_sales_vs_listings_city_filter.sql" + +_OLD_SIGNATURE = "street_sales_vs_listings(text, numeric, integer, integer, numeric, integer)" +_NEW_SIGNATURE_PARAMS = ( + "text", + "numeric", + "integer", + "integer", + "numeric", + "integer", + "text", +) + + +def _sql() -> str: + return _MIGRATION_205.read_text(encoding="utf-8") + + +def _executable_sql() -> str: + """SQL без построчных `--`-комментариев — только исполняемый код.""" + lines = [] + for raw in _sql().splitlines(): + code = raw.split("--", 1)[0] + if code.strip(): + lines.append(code) + return "\n".join(lines) + + +def _flat(text: str) -> str: + return re.sub(r"\s+", " ", text).strip().lower() + + +def test_migration_205_exists() -> None: + assert _MIGRATION_205.exists(), f"missing migration: {_MIGRATION_205}" + + +def test_migration_205_is_transactional() -> None: + sql = _sql() + assert "BEGIN;" in sql + assert "COMMIT;" in sql + + +def test_migration_205_no_destructive_ddl() -> None: + """Только DROP FUNCTION (сигнатура меняется) + CREATE OR REPLACE FUNCTION — + никакого DROP/ALTER TABLE, TRUNCATE (таблицы deals/listings не трогаются).""" + flat = _flat(_executable_sql()) + assert "drop table" not in flat + assert "alter table" not in flat + assert "truncate" not in flat + + +def test_migration_205_drops_old_signature_before_replace() -> None: + """CREATE OR REPLACE FUNCTION с добавленным параметром создаёт НОВУЮ + перегрузку (Postgres матчит по списку типов аргументов) — старую 6-арг + сигнатуру нужно дропнуть явно, иначе останутся два оверлоада одной функции. + DROP FUNCTION IF EXISTS идемпотентен: на повторном прогоне (функция уже + 7-арг) — no-op, ошибки нет.""" + flat = _flat(_executable_sql()) + assert f"drop function if exists {_OLD_SIGNATURE.lower()}" in flat + + +def test_migration_205_creates_new_signature_with_target_city_default_null() -> None: + """Новый параметр p_target_city — СЕДЬМОЙ, DEFAULT NULL (обратная + совместимость с любым caller'ом на 6 позиционных аргументах).""" + sql = _sql() + assert "CREATE OR REPLACE FUNCTION street_sales_vs_listings(" in sql + assert "p_target_city text DEFAULT NULL" in sql + # Порядок параметров ВНУТРИ сигнатуры (не в header-комментариях, которые + # упоминают p_target_city раньше по тексту файла): p_target_city должен + # идти ПОСЛЕ p_period_months (седьмым, не разрывая позиционную сигнатуру). + sig_start = sql.index("CREATE OR REPLACE FUNCTION street_sales_vs_listings(") + sig_body = sql[sig_start:] + period_pos = sig_body.index("p_period_months") + city_pos = sig_body.index("p_target_city") + assert period_pos < city_pos + + +def test_migration_205_comment_on_function_matches_new_signature() -> None: + """COMMENT ON FUNCTION должен ссылаться на НОВУЮ (7-арг) сигнатуру — + иначе COMMENT молча создаст comment на несуществующий оверлоад / упадёт.""" + flat = _flat(_executable_sql()) + new_sig = "street_sales_vs_listings(" + ", ".join(_NEW_SIGNATURE_PARAMS) + ")" + assert f"comment on function {new_sig.lower()}" in flat + + +def test_migration_205_deals_side_city_predicate_strict_with_null_fallback() -> None: + """deals.city заполнена на 100% (прод-замер) → строгое равенство при + p_target_city заданном; p_target_city IS NULL (город вне словаря, H1) → + фильтр не применяется — тот же fallback, что и /street-deals.""" + flat = _flat(_executable_sql()) + assert "(p_target_city is null or lower(d.city) = lower(p_target_city))" in flat + + +def test_migration_205_listings_side_city_predicate_tolerant_to_null() -> None: + """listings.city заполнена частично (avito ~63%, yandex ~19%, cian ~4.6%, + domklik ~0.6%, n1 ~0%) — NULL считается "своим" (симметрично + asking_to_sold_ratio.py #2583 H2), иначе строгий фильтр выбросил бы + почти все listings кроме avito.""" + flat = _flat(_executable_sql()) + assert ( + "(p_target_city is null or l.city is null or lower(l.city) = lower(p_target_city))" in flat + ) + + +def test_migration_205_no_psycopg_cast_trap() -> None: + """Никаких :param::type — psycopg v3 требует CAST(... AS type) (не применимо + в чистом .sql без bind params здесь, но проверяем на регресс copy-paste).""" + assert not re.search(r":\w+::", _sql()) + + +def test_migration_205_return_table_shape_unchanged() -> None: + """RETURNS TABLE(...) columns остаются теми же, что в 067 — endpoint + (trade_in.py) читает их по имени через .mappings(), любое переименование/ + удаление сломало бы response mapping без явного сигнала.""" + sql = _sql() + for col in ( + "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", + ): + assert col in sql, f"missing column in RETURNS TABLE: {col}" + + +def test_migration_205_idempotent_by_construction() -> None: + """DROP FUNCTION IF EXISTS (старая сигнатура) + CREATE OR REPLACE (новая) — + оба идемпотентны по конструкции, отдельного guard-условия не требуется.""" + flat = _flat(_executable_sql()) + assert flat.count("drop function if exists") == 1 + assert flat.count("create or replace function street_sales_vs_listings") == 1 diff --git a/tradein-mvp/backend/tests/test_sales_vs_listings.py b/tradein-mvp/backend/tests/test_sales_vs_listings.py index 39472da2..184c785d 100644 --- a/tradein-mvp/backend/tests/test_sales_vs_listings.py +++ b/tradein-mvp/backend/tests/test_sales_vs_listings.py @@ -335,6 +335,56 @@ def test_sales_vs_listings_passes_proper_params(trade_in_app: FastAPI) -> None: assert params["period_months"] == 12 +# ── Test: city-scope propagation (#2583 H4) ─────────────────────────────────── + + +def test_sales_vs_listings_passes_resolved_target_city(trade_in_app: FastAPI) -> None: + """#2583 H4: адрес с распознаваемым городом (словарь SVERDLOVSK_OBLAST_CITIES) + должен прокидывать target_city в street_sales_vs_listings() — иначе пары + склеиваются с другими городами (зеркало /street-deals #C1).""" + db_mock = _make_db_mock([]) + _override_db(trade_in_app, db_mock) + + client = TestClient(trade_in_app) + resp = client.get( + "/api/v1/trade-in/sales-vs-listings", + params={ + "address": "Нижний Тагил, ул. Ленина, 5", + "area_m2": 44.3, + "rooms": 2, + }, + ) + assert resp.status_code == 200 + assert db_mock.execute.called + args, kwargs = db_mock.execute.call_args + params = args[1] if len(args) > 1 else kwargs.get("parameters", {}) + assert params["target_city"] == "нижний тагил" + + +def test_sales_vs_listings_target_city_none_when_city_unresolved( + trade_in_app: FastAPI, +) -> None: + """Адрес вне словаря SVERDLOVSK_OBLAST_CITIES (известная H1) → target_city=None, + TVF-сторона не фильтрует по городу — тот же fallback, что и /street-deals.""" + db_mock = _make_db_mock([]) + _override_db(trade_in_app, db_mock) + + client = TestClient(trade_in_app) + resp = client.get( + "/api/v1/trade-in/sales-vs-listings", + params={ + "address": "Верхняя Синячиха, ул. Ленина, 5", + "area_m2": 44.3, + "rooms": 2, + }, + ) + assert resp.status_code == 200 + assert db_mock.execute.called + args, kwargs = db_mock.execute.call_args + params = args[1] if len(args) > 1 else kwargs.get("parameters", {}) + assert params["target_city"] is None + + # ── Test: response shape (Pydantic validation) ───────────────────────────────