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
Owner

Summary

  • street_sales_vs_listings() (data/sql/067_v_street_sales_vs_listings.sql) строила пары ДКП-сделка/объявление только по street_pattern ILIKE + rooms + area + дата — без корреляции города. deals.address/listings.address хранят "<Город>, <Улица>", а street_pattern — голое имя улицы, которое одинаково матчит одноимённые улицы разных городов обл.66 ("Ленина", "Красноармейская", ...).
  • Фикс зеркалит уже принятый паттерн /street-deals (#C1, trade_in.py:1717): target_city резолвится через _resolve_target_city(address) (тот же словарь ~30 городов обл.66) и передаётся в TVF седьмым параметром p_target_city text DEFAULT NULL.
  • deals-сторона фильтруется строго (LOWER(d.city) = LOWER(p_target_city)) — deals.city заполнена на 100% (прод-замер). listings-сторона терпима к NULL (l.city IS NULL OR LOWER(l.city) = LOWER(p_target_city)) — listings.city заполнена частично (прод-замер: avito 63%, yandex 19%, cian 4.6%, domklik 0.6%, n1 0%), симметрично уже принятому паттерну asking_to_sold_ratio.py (#2583 H2, PR #2617).
  • p_target_city IS NULL (адрес вне словаря городов, известная H1) → фильтр не применяется ни на одной стороне — тот же fallback, что уже принят в /street-deals, для консистентности между соседними виджетами на одной странице.

Три решения (с обоснованием)

  1. Откуда брать город: _resolve_target_city(address) — идентично /street-deals. Для адресов вне словаря (~30 городов обл.66) возвращает None → фильтр не применяется (текущее поведение сохраняется как fallback). Альтернатива (пустой результат при None) отклонена: создала бы расхождение с соседним /street-deals для одного и того же адреса на одной странице — хуже, чем редкий edge-case без фильтра. H1 (список городов) — отдельная известная находка, чинится отдельным PR.
  2. Фильтр на обеих сторонах: deals — строгое равенство (city заполнена на 100%), listings — терпимо к NULL (city заполнена частично: avito 63%, yandex 19%, cian 4.6%, domklik 0.6%, n1 0% — прод-замер ниже). Строгий фильтр без IS NULL выбросил бы ~80-95% listings кроме avito.
  3. Совместимость сигнатуры: единственный caller — trade_in.py:1865. p_target_city добавлен СЕДЬМЫМ параметром с DEFAULT NULL. Т.к. CREATE OR REPLACE FUNCTION с добавленным параметром создаёт НОВУЮ перегрузку (Postgres матчит по списку типов аргументов), миграция явно дропает старую 6-арг сигнатуру (DROP FUNCTION IF EXISTS ... — идемпотентно) ПЕРЕД CREATE OR REPLACE с новой. Convenience view v_street_sales_vs_listings из 067 уже дропнута миграцией 068 (была без street-match, генерила 50k spurious pairs) — фиксить нечего.

Прод-замер (read-only, Нижний Тагил + Ленина, rooms=2, area≈44.3м², defaults)

BEFORE (буг) AFTER (фикс)
total_deals (Тагил) 8 8
with_listing 8 (100%) 6 (75%)
explicit cross-city 4/8 (50%) 0/6 (0%)
listing_city=NULL (неизвестно) 4/8 6/6
median_discount_pct -44.67% -12.73%

Шире (street="Ленина" по ВСЕМ городам обл.66 с этим именем улицы, rooms=2, area≈44.3): 352 total pairs, 244 с listing-match, из них 119 (48.8%) явно чужого города + 122 (50%) NULL-city (Циан/Домклик/Яндекс) — только ~10% подтверждённо того же города. median_discount_pct на смеси городов = -63.6% (тот же порядок, что заявленные в находке -59%; точное число зависит от конкретного адреса/выборки исходного аудита, которую не удалось восстановить бит-в-бит — воспроизведена качественно та же картина: подавляющее большинство пар — чужой город, median сильно завышен по модулю).

Контроль (Екатеринбург, та же улица/rooms/area): BEFORE и AFTER идентичны — 14 total, 7 with_listing, 0 explicit cross-city, 4 NULL-city, median +11.11% — без деградации (у ЕКБ и так не было явных cross-city, avito доминирует и покрывает EKB лучше всего).

EXPLAIN (без ANALYZE): city-предикат добавляется в существующий Filter на тех же Bitmap Heap Scan (по deals_address_trgm_idx / deals_rooms_area_idx / listings_address_trgm_idx) — не меняет стратегию join, cost практически не меняется (2404→2383, в пределах шума оценки планировщика). Индекса на listings.city нет, но он не нужен: фильтр применяется постфактум на уже отобранных ILIKE-строках.

Test plan

  • tests/test_migration_205_sales_vs_listings_city_filter.py — новый: транзакционность, DROP FUNCTION (старая сигнатура) идемпотентно, новая 7-арг сигнатура с p_target_city DEFAULT NULL, city-предикаты на обеих сторонах (deals строго / listings терпимо к NULL), отсутствие CAST-ловушки psycopg, RETURNS TABLE не менялся.
  • tests/test_sales_vs_listings.py — 2 новых теста: target_city резолвится и прокидывается для распознанного города; target_city=None для адреса вне словаря (H1 fallback).
  • Полный pytest в tradein-mvp/backend: 3112 passed, 9 skipped, 1 known pre-existing fail (test_search_api.py::test_search_cache_hit, 401 RBAC — не связан с этим PR, не чинится по заданию).
  • Live SQL verify на проде (read-only) — см. таблицу выше.

Refs #2583

## Summary - `street_sales_vs_listings()` (`data/sql/067_v_street_sales_vs_listings.sql`) строила пары ДКП-сделка/объявление только по `street_pattern ILIKE` + rooms + area + дата — без корреляции города. `deals.address`/`listings.address` хранят `"<Город>, <Улица>"`, а street_pattern — голое имя улицы, которое одинаково матчит одноимённые улицы разных городов обл.66 ("Ленина", "Красноармейская", ...). - Фикс зеркалит уже принятый паттерн `/street-deals` (#C1, `trade_in.py:1717`): `target_city` резолвится через `_resolve_target_city(address)` (тот же словарь ~30 городов обл.66) и передаётся в TVF седьмым параметром `p_target_city text DEFAULT NULL`. - deals-сторона фильтруется строго (`LOWER(d.city) = LOWER(p_target_city)`) — `deals.city` заполнена на 100% (прод-замер). listings-сторона терпима к `NULL` (`l.city IS NULL OR LOWER(l.city) = LOWER(p_target_city)`) — `listings.city` заполнена частично (прод-замер: avito 63%, yandex 19%, cian 4.6%, domklik 0.6%, n1 0%), симметрично уже принятому паттерну `asking_to_sold_ratio.py` (#2583 H2, PR #2617). - `p_target_city IS NULL` (адрес вне словаря городов, известная H1) → фильтр не применяется ни на одной стороне — тот же fallback, что уже принят в `/street-deals`, для консистентности между соседними виджетами на одной странице. ## Три решения (с обоснованием) 1. **Откуда брать город**: `_resolve_target_city(address)` — идентично `/street-deals`. Для адресов вне словаря (~30 городов обл.66) возвращает `None` → фильтр не применяется (текущее поведение сохраняется как fallback). Альтернатива (пустой результат при `None`) отклонена: создала бы расхождение с соседним `/street-deals` для одного и того же адреса на одной странице — хуже, чем редкий edge-case без фильтра. H1 (список городов) — отдельная известная находка, чинится отдельным PR. 2. **Фильтр на обеих сторонах**: deals — строгое равенство (city заполнена на 100%), listings — терпимо к `NULL` (city заполнена частично: avito 63%, yandex 19%, cian 4.6%, domklik 0.6%, n1 0% — прод-замер ниже). Строгий фильтр без `IS NULL` выбросил бы ~80-95% listings кроме avito. 3. **Совместимость сигнатуры**: единственный caller — `trade_in.py:1865`. `p_target_city` добавлен СЕДЬМЫМ параметром с `DEFAULT NULL`. Т.к. `CREATE OR REPLACE FUNCTION` с добавленным параметром создаёт НОВУЮ перегрузку (Postgres матчит по списку типов аргументов), миграция явно дропает старую 6-арг сигнатуру (`DROP FUNCTION IF EXISTS ...` — идемпотентно) ПЕРЕД `CREATE OR REPLACE` с новой. Convenience view `v_street_sales_vs_listings` из 067 уже дропнута миграцией 068 (была без street-match, генерила 50k spurious pairs) — фиксить нечего. ## Прод-замер (read-only, `Нижний Тагил` + `Ленина`, rooms=2, area≈44.3м², defaults) | | BEFORE (буг) | AFTER (фикс) | |---|---|---| | total_deals (Тагил) | 8 | 8 | | with_listing | 8 (100%) | 6 (75%) | | explicit cross-city | **4/8 (50%)** | **0/6 (0%)** | | listing_city=NULL (неизвестно) | 4/8 | 6/6 | | median_discount_pct | **-44.67%** | **-12.73%** | Шире (street="Ленина" по ВСЕМ городам обл.66 с этим именем улицы, rooms=2, area≈44.3): 352 total pairs, 244 с listing-match, из них 119 (48.8%) явно чужого города + 122 (50%) NULL-city (Циан/Домклик/Яндекс) — только ~10% подтверждённо того же города. median_discount_pct на смеси городов = **-63.6%** (тот же порядок, что заявленные в находке -59%; точное число зависит от конкретного адреса/выборки исходного аудита, которую не удалось восстановить бит-в-бит — воспроизведена качественно та же картина: подавляющее большинство пар — чужой город, median сильно завышен по модулю). **Контроль (Екатеринбург, та же улица/rooms/area)**: BEFORE и AFTER идентичны — 14 total, 7 with_listing, 0 explicit cross-city, 4 NULL-city, median +11.11% — без деградации (у ЕКБ и так не было явных cross-city, avito доминирует и покрывает EKB лучше всего). `EXPLAIN` (без ANALYZE): city-предикат добавляется в существующий `Filter` на тех же Bitmap Heap Scan (по `deals_address_trgm_idx` / `deals_rooms_area_idx` / `listings_address_trgm_idx`) — не меняет стратегию join, cost практически не меняется (2404→2383, в пределах шума оценки планировщика). Индекса на `listings.city` нет, но он не нужен: фильтр применяется постфактум на уже отобранных ILIKE-строках. ## Test plan - [x] `tests/test_migration_205_sales_vs_listings_city_filter.py` — новый: транзакционность, DROP FUNCTION (старая сигнатура) идемпотентно, новая 7-арг сигнатура с `p_target_city DEFAULT NULL`, city-предикаты на обеих сторонах (deals строго / listings терпимо к NULL), отсутствие CAST-ловушки psycopg, RETURNS TABLE не менялся. - [x] `tests/test_sales_vs_listings.py` — 2 новых теста: `target_city` резолвится и прокидывается для распознанного города; `target_city=None` для адреса вне словаря (H1 fallback). - [x] Полный `pytest` в `tradein-mvp/backend`: 3112 passed, 9 skipped, 1 known pre-existing fail (`test_search_api.py::test_search_cache_hit`, 401 RBAC — не связан с этим PR, не чинится по заданию). - [x] Live SQL verify на проде (read-only) — см. таблицу выше. Refs #2583
lekss361 added 1 commit 2026-08-02 11:50:52 +00:00
fix(tradein/ui): городской фильтр в витрине «сделки против объявлений» (#2583 H4)
All checks were successful
CI / changes (pull_request) Successful in 10s
CI Trade-In / changes (pull_request) Successful in 10s
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 2m44s
6052ba3a81
street_sales_vs_listings() строила пары ДКП-сделка/объявление только по
совпадению улицы (ILIKE) + rooms + area + дата — без корреляции города.
deals.address/listings.address хранят "<Город>, <Улица>", а street_pattern —
голое имя улицы, которое одинаково матчит одноимённые улицы разных городов
обл.66. Прод-репро: Нижний Тагил + Ленина, rooms=2, area~44.3 — 4 из 8
сделок получали listing-match из другого города, median_discount_pct
уезжал в -44.7% (широкая выборка по всем городам с этой улицей: -63.6%).

Фикс зеркалит уже принятый паттерн /street-deals (#C1): target_city
резолвится через _resolve_target_city(address) и передаётся в TVF
седьмым параметром (DEFAULT NULL — обратная совместимость). deals-сторона
фильтруется строго (city заполнена на 100%), listings-сторона терпима к
NULL (city заполнена частично: avito 63%, yandex 19%, cian 4.6%, domklik
0.6%, n1 0%) — симметрично паттерну asking_to_sold_ratio.py (#2583 H2).
lekss361 merged commit c1b407527b into main 2026-08-02 11:54:34 +00:00
lekss361 deleted branch fix/tradein-sales-vs-listings-city 2026-08-02 11:54:34 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#2627
No description provided.