fix(tradein/ui): городской фильтр в витрине «сделки против объявлений» (#2583 H4) (#2627)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m39s
Deploy Trade-In / build-backend (push) Successful in 1m1s
Deploy Trade-In / deploy (push) Successful in 1m17s
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m39s
Deploy Trade-In / build-backend (push) Successful in 1m1s
Deploy Trade-In / deploy (push) Successful in 1m17s
This commit is contained in:
parent
7c9319d3b9
commit
c1b407527b
4 changed files with 444 additions and 2 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
@ -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) ───────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue