Витрина «сделки против объявлений» перестаёт пустовать: снят предикат по синтетической комнатности сделок (#3451)
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-browser (push) Successful in 38s
Deploy Trade-In / build-frontend (push) Successful in 2m15s
Deploy Trade-In / test (push) Successful in 4m35s
Deploy Trade-In / build-backend (push) Successful in 1m5s
Deploy Trade-In / deploy (push) Successful in 1m31s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m41s
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-browser (push) Successful in 38s
Deploy Trade-In / build-frontend (push) Successful in 2m15s
Deploy Trade-In / test (push) Successful in 4m35s
Deploy Trade-In / build-backend (push) Successful in 1m5s
Deploy Trade-In / deploy (push) Successful in 1m31s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m41s
This commit is contained in:
commit
5f2810b8c6
4 changed files with 374 additions and 6 deletions
|
|
@ -2488,8 +2488,12 @@ def get_sales_vs_listings(
|
||||||
"""Pairs (ДКП-сделка, listing) для улицы целевого адреса (PR K / #564).
|
"""Pairs (ДКП-сделка, listing) для улицы целевого адреса (PR K / #564).
|
||||||
|
|
||||||
Для каждой ДКП-сделки Росреестра в окне `period_months` пытаемся найти
|
Для каждой ДКП-сделки Росреестра в окне `period_months` пытаемся найти
|
||||||
matching listing на той же улице с такими же rooms / близкой area_m2 /
|
matching listing на той же улице с близкой area_m2 / listing_date в окне
|
||||||
listing_date в окне [deal_date - window_days, deal_date + 30d grace].
|
[deal_date - window_days, deal_date + 30d grace]. Комнатность в ключе стоит
|
||||||
|
ТОЛЬКО на стороне объявлений (`l.rooms = p_rooms`, комнатность клиента): у
|
||||||
|
сделок Росреестра `rooms` — синтетика из площади, предикат по ней снят
|
||||||
|
миграцией 300 (#3451/#3256). Поэтому `deal_rooms` в паре может не совпадать
|
||||||
|
с запрошенным `rooms`.
|
||||||
|
|
||||||
Возвращаем LEFT JOIN: сделки без listing match сохраняются (listing_* = None),
|
Возвращаем LEFT JOIN: сделки без listing match сохраняются (listing_* = None),
|
||||||
чтобы вычислить linkage_rate.
|
чтобы вычислить linkage_rate.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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
|
||||||
|
|
@ -76,10 +76,15 @@ docker exec "$SRC_PG" psql -U "$SRC_USER" -d "$SRC_DB" -v ON_ERROR_STOP=on -c "
|
||||||
-- НЕ фильтруют по deals.rooms ИМЕННО потому, что здесь синтетика; с
|
-- НЕ фильтруют по deals.rooms ИМЕННО потому, что здесь синтетика; с
|
||||||
-- настоящей комнатностью предикат нужно вернуть, иначе все сайты молча
|
-- настоящей комнатностью предикат нужно вернуть, иначе все сайты молча
|
||||||
-- продолжат ключеваться площадью.
|
-- продолжат ключеваться площадью.
|
||||||
-- ОСТАВШИЙСЯ потребитель, который ВСЁ ЕЩЁ фильтрует по d.rooms:
|
-- Потребителей, фильтрующих РАВЕНСТВОМ по d.rooms, больше нет: последний —
|
||||||
-- TVF street_sales_vs_listings (data/sql/211_sales_vs_listings_segment_guard.sql:89),
|
-- TVF street_sales_vs_listings — расчищен миграцией 300 (#3451). Ключ там
|
||||||
-- её зовёт API /sales-vs-listings (trade_in.py). Там ключ асимметричный —
|
-- асимметричный: d.rooms снят, l.rooms ОСТАВЛЕН (у объявлений комнатность
|
||||||
-- d.rooms синтетика, l.rooms настоящая, — копипастой из #3256 не чинится.
|
-- настоящая). Появится новый потребитель — сверься с этим якорем.
|
||||||
|
-- НО: app/tasks/asking_to_sold_ratio.py:148,152 по-прежнему КЛЮЧУЕТСЯ этим
|
||||||
|
-- бакетом (GROUP BY LEAST(GREATEST(rooms,0),4)) и намеренно зеркалит ту же
|
||||||
|
-- синтетику на листинговой стороне (#2620). Поменяешь CASE на реальную
|
||||||
|
-- комнатность — вернётся именно #2620 (миграция 23-55% объявлений между
|
||||||
|
-- бакетами, ratio>1 в «4+»), а не только вопрос предикатов.
|
||||||
CASE
|
CASE
|
||||||
WHEN area < 30 THEN 0 WHEN area < 44 THEN 1
|
WHEN area < 30 THEN 0 WHEN area < 44 THEN 1
|
||||||
WHEN area < 62 THEN 2 WHEN area < 85 THEN 3
|
WHEN area < 62 THEN 2 WHEN area < 85 THEN 3
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue