fix(tradein/ui): городской фильтр в витрине «сделки против объявлений» (#2583 H4) #2627

Merged
lekss361 merged 1 commit from fix/tradein-sales-vs-listings-city into main 2026-08-02 11:54:34 +00:00
4 changed files with 444 additions and 2 deletions

View file

@ -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()

View file

@ -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;

View file

@ -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

View file

@ -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) ───────────────────────────────