Merge pull request 'fix(tradein): сегментный гард в «медианном торге», свежесть в индексе локации, честные админ-счётчики (#2660)' (#2664) from fix/2660-display-freshness-segment into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
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 2m38s
Deploy Trade-In / build-backend (push) Successful in 1m3s
Deploy Trade-In / deploy (push) Successful in 1m23s
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
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 2m38s
Deploy Trade-In / build-backend (push) Successful in 1m3s
Deploy Trade-In / deploy (push) Successful in 1m23s
This commit is contained in:
commit
63ea44fdd2
8 changed files with 775 additions and 6 deletions
|
|
@ -72,6 +72,7 @@ from app.services import cian_session as cian_session_svc
|
|||
from app.services import domclick_session as domclick_session_svc
|
||||
from app.services import proxy_rotation as proxy_rotation_svc
|
||||
from app.services import scrape_runs as runs_mod
|
||||
from app.services.estimator import LISTINGS_FRESH_DAYS
|
||||
from app.services.geocoder import geocode, known_city_hint
|
||||
from app.services.proxy_pool import clear_source_bans
|
||||
from app.services.scheduler import has_running_run
|
||||
|
|
@ -2473,6 +2474,12 @@ async def update_scraper_pacing(
|
|||
class SourceCoverage(BaseModel):
|
||||
source: str
|
||||
active_count: int
|
||||
# #2660: «активно» ≠ «живо». is_active снимается только деактиватором протухших,
|
||||
# а он покрывает не все источники — на проде (2026-08-05) cian показывал 18 530
|
||||
# активных при 12 683 не виденных 14+ дней. Из-за этого #2574 месяц читалась как
|
||||
# «всё собирается». Не прячем протухшее из счётчика, а отдаём ВТОРЫМ числом
|
||||
# рядом — тогда «активно» перестаёт читаться как «живо».
|
||||
stale_count: int
|
||||
fields: dict[str, float] # field_name -> fill% (0..100, round 1)
|
||||
|
||||
|
||||
|
|
@ -2487,6 +2494,9 @@ class HousesCoverage(BaseModel):
|
|||
class DataQualityResponse(BaseModel):
|
||||
sources: list[SourceCoverage]
|
||||
houses: HousesCoverage
|
||||
# Порог «не виделись N дней» для stale_count — отдаём в ответе, чтобы UI
|
||||
# подписывал число, а не хардкодил порог у себя вторым определением.
|
||||
stale_days: int
|
||||
|
||||
|
||||
# Поля listings для fill%-аудита. Каждый кортеж: (имя_поля, SQL-выражение IS NOT NULL).
|
||||
|
|
@ -2516,6 +2526,10 @@ def get_data_quality(
|
|||
living_area_m2, ceiling_height (cian), ceiling_height_m (avito), metro_stations.
|
||||
houses: total, avito_validated_at%, rating_score%, house_type%.
|
||||
house_reviews: общий count.
|
||||
|
||||
#2660: рядом с active_count отдаётся stale_count — сколько из «активных» не
|
||||
виделись LISTINGS_FRESH_DAYS дней (last_seen_at). Порог отдаётся в ответе
|
||||
(stale_days), чтобы UI не заводил второе определение.
|
||||
"""
|
||||
# Строим single-pass SELECT для listings полей через FILTER-агрегаты.
|
||||
# Структура: COUNT(*) FILTER (WHERE <expr>) / NULLIF(COUNT(*), 0) * 100
|
||||
|
|
@ -2523,10 +2537,17 @@ def get_data_quality(
|
|||
filter_exprs = ", ".join(
|
||||
f"COUNT(*) FILTER (WHERE {expr}) AS f_{name}" for name, expr in _DQ_LISTING_FIELDS
|
||||
)
|
||||
# last_seen_at, а не scraped_at: счётчик отвечает буквально на «сколько не
|
||||
# виделись». На проде две колонки не расходятся (замер 2026-08-05: 0 активных
|
||||
# строк с разницей ≥ суток), но семантика счётчика — про «видели», и колонка
|
||||
# должна называть ровно её.
|
||||
sql_listings = text(f"""
|
||||
SELECT
|
||||
source,
|
||||
COUNT(*) AS active_count,
|
||||
COUNT(*) FILTER (
|
||||
WHERE last_seen_at <= NOW() - (:fresh_days || ' days')::interval
|
||||
) AS stale_count,
|
||||
{filter_exprs}
|
||||
FROM listings
|
||||
WHERE is_active = true
|
||||
|
|
@ -2534,7 +2555,7 @@ def get_data_quality(
|
|||
ORDER BY source
|
||||
""")
|
||||
|
||||
rows = db.execute(sql_listings).mappings().all()
|
||||
rows = db.execute(sql_listings, {"fresh_days": LISTINGS_FRESH_DAYS}).mappings().all()
|
||||
|
||||
sources: list[SourceCoverage] = []
|
||||
for row in rows:
|
||||
|
|
@ -2548,6 +2569,7 @@ def get_data_quality(
|
|||
SourceCoverage(
|
||||
source=row["source"],
|
||||
active_count=int(row["active_count"]),
|
||||
stale_count=int(row["stale_count"] or 0),
|
||||
fields=fields,
|
||||
)
|
||||
)
|
||||
|
|
@ -2575,7 +2597,7 @@ def get_data_quality(
|
|||
reviews_count=reviews_count,
|
||||
)
|
||||
|
||||
return DataQualityResponse(sources=sources, houses=houses)
|
||||
return DataQualityResponse(sources=sources, houses=houses, stale_days=LISTINGS_FRESH_DAYS)
|
||||
|
||||
|
||||
# ── Proxy pool: хранилище + bulk-загрузка / список (#2161) ───────────────────
|
||||
|
|
|
|||
|
|
@ -750,7 +750,15 @@ def cache_stats(db: Annotated[Session, Depends(get_db)]) -> dict[str, object]:
|
|||
trade_in_estimates с непустым address; NULL при отсутствии адресов.
|
||||
NB: это честный best-effort по persisted оценкам, а не hit-rate реального
|
||||
кэша (отдельного счётчика попаданий не ведём).
|
||||
|
||||
#2660: listings_active сам по себе врал — «активно» на проде не означает
|
||||
«живо» (деактиватор протухших покрывает не все источники). Рядом отдаём
|
||||
listings_active_stale — сколько из них не виделись listings_stale_days
|
||||
(= LISTINGS_FRESH_DAYS эстиматора; прод 2026-08-05: 37 900 активных при
|
||||
20 935 не виденных 14+ дней). Счётчик не прячем, а разделяем.
|
||||
"""
|
||||
from app.services.estimator import LISTINGS_FRESH_DAYS
|
||||
|
||||
row = (
|
||||
db.execute(
|
||||
text(
|
||||
|
|
@ -760,6 +768,10 @@ def cache_stats(db: Annotated[Session, Depends(get_db)]) -> dict[str, object]:
|
|||
(SELECT count(*) FROM geocode_cache WHERE expires_at > NOW())
|
||||
AS geocode_cache_fresh,
|
||||
(SELECT count(*) FROM listings WHERE is_active) AS listings_active,
|
||||
(SELECT count(*) FROM listings
|
||||
WHERE is_active
|
||||
AND last_seen_at <= NOW() - (:fresh_days || ' days')::interval)
|
||||
AS listings_active_stale,
|
||||
(SELECT max(scraped_at) FROM listings) AS listings_last_scraped,
|
||||
(SELECT count(*) FROM deals) AS deals,
|
||||
(SELECT count(*) FROM gendesign_cad_buildings) AS cad_buildings,
|
||||
|
|
@ -776,12 +788,15 @@ def cache_stats(db: Annotated[Session, Depends(get_db)]) -> dict[str, object]:
|
|||
WHERE address IS NOT NULL AND address <> ''
|
||||
) t) AS repeat_address_pct
|
||||
"""
|
||||
)
|
||||
),
|
||||
{"fresh_days": LISTINGS_FRESH_DAYS},
|
||||
)
|
||||
.mappings()
|
||||
.fetchone()
|
||||
)
|
||||
return dict(row) if row else {}
|
||||
# Порог отдаём рядом с числом — чтобы UI подписывал «не виделись N дней»,
|
||||
# а не заводил второе определение свежести у себя.
|
||||
return (dict(row) | {"listings_stale_days": LISTINGS_FRESH_DAYS}) if row else {}
|
||||
|
||||
|
||||
# ── Stage 4a: house info + IMV benchmark для UI ───────────────────────────────
|
||||
|
|
|
|||
|
|
@ -13,9 +13,13 @@
|
|||
POI-score его не улавливал (POI ranking ≠ цена).
|
||||
|
||||
НОВЫЙ ПОКАЗАТЕЛЬ (location index):
|
||||
location_index_pct = (медиана ₽/м² сопоставимых активных листингов в радиусе точки −
|
||||
location_index_pct = (медиана ₽/м² сопоставимых листингов в радиусе точки −
|
||||
медиана ₽/м² по всему ЕКБ) / медиана по ЕКБ * 100
|
||||
|
||||
«Сопоставимые» = ровно тот же пул, что берёт эстиматор (#2660): активные И свежие
|
||||
(scraped_at в пределах LISTINGS_FRESH_DAYS — `is_active` на проде не равно «живо») И
|
||||
только вторичка (гард #1186 — девелоперский прайс новостроек завышал обе медианы).
|
||||
|
||||
Самообновляем (те же `listings`, что уже скрейпятся под estimator), интерпретируем напрямую
|
||||
("район на N% дороже/дешевле среднего по городу"), устойчив к выбросам (percentile_cont(0.5) —
|
||||
медиана самой природой игнорирует единичные экстремумы, в отличие от mean/min/max), и НЕ зажат
|
||||
|
|
@ -45,6 +49,11 @@ from typing import Any
|
|||
from pydantic import BaseModel
|
||||
from sqlalchemy import text
|
||||
|
||||
# #2660: окно свежести берём ИЗ эстиматора — единственное определение в проекте.
|
||||
# Дублировать значение здесь нельзя: две константы разъедутся при первой же
|
||||
# перекалибровке, и витрина начнёт показывать другой пул, чем считает цена.
|
||||
from app.services.estimator import LISTINGS_FRESH_DAYS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Гео-охват продукта: только Екатеринбург ──────────────────────────────────
|
||||
|
|
@ -159,6 +168,43 @@ def _pct_deviation(local_median_ppm2: float, city_median_ppm2: float) -> float:
|
|||
# price_per_m2 BETWEEN sanity-границы — не бизнес-калибровка, а защита от битых строк
|
||||
# (см. _PRICE_PER_M2_SANITY_MIN/MAX выше).
|
||||
#
|
||||
# #2660 свежесть + сегмент — оба предиката ЗЕРКАЛЯТ _COMMON_WHERE эстиматора.
|
||||
# Вклад у них РАЗНЫЙ, и не тот, на который легко подумать. Прод-разложение
|
||||
# (2026-08-05, пул location_index — bbox ЕКБ + sanity ₽/м² + geo_precision):
|
||||
#
|
||||
# было (только is_active) 30 222 строк 172 984 ₽/м²
|
||||
# + только свежесть 11 453 строк 163 363 ₽/м²
|
||||
# + только сегмент 11 219 строк 147 632 ₽/м²
|
||||
# стало (оба) 7 715 строк 147 368 ₽/м²
|
||||
#
|
||||
# - listing_segment guard (#1186) — ЭТО и есть исправление смещения: из −14.8%
|
||||
# сдвига городской медианы он даёт −14.7 п.п. Девелоперский прайс новостроек
|
||||
# завышал и локальную, и городскую медиану. NULL = legacy вторичка до м.011.
|
||||
# Мертвецы, кстати, живут почти целиком тут же: из 18 769 протухших строк
|
||||
# пула 15 265 — новостройки, и гард выносит их заодно.
|
||||
# - scraped_at > NOW() - LISTINGS_FRESH_DAYS — даёт ПОВЕРХ сегмента всего
|
||||
# −0.18 п.п. Для ЭТОЙ метрики он не коррекция смещения, а СТРАХОВКА на
|
||||
# будущее (пул совпадает с пулом цены; если завтра протухнет вторичка —
|
||||
# виджет не соврёт), и страховка не бесплатная: выбрасывает 3 504 вторичных
|
||||
# строки, из которых 2 724 — живые объявления, отскрейпленные 15-30 дней
|
||||
# назад. Пул −31%, шум растёт: на центре ЕКБ (r=800) n падает 423 → 86, а
|
||||
# сам индекс гуляет по выбору окна на 12-14 п.п. (7д +75.7% / 14д +77.0% /
|
||||
# 21д +79.1% / 30д +64.7%) — при n=86 это в пределах шума выборки медианы.
|
||||
# Размен «меньше смещения ↔ больше дисперсии» сделан осознанно: старое число
|
||||
# было предвзятым, новое — шумным, но честным. Окно менять здесь НЕ надо,
|
||||
# LISTINGS_FRESH_DAYS живёт в estimator.py (см. импорт выше).
|
||||
#
|
||||
# НОВЫЙ РЕЖИМ ОТКАЗА (знать обязательно): свежесть связала витрину со здоровьем
|
||||
# СБОРА. Встанет скрейпинг на LISTINGS_FRESH_DAYS — городская выборка не наберёт
|
||||
# MIN_SAMPLE_SIZE, и "insufficient_data" прилетит ВСЕМ пользователям разом; до
|
||||
# этой правки виджет продолжал бы показывать устаревшее число. Учитывая, что
|
||||
# #2574 — ровно месяц молчаливой поломки сбора, сценарий не гипотетический.
|
||||
# Деградация честная (прочерк, а не выдуманное число), но она теперь массовая.
|
||||
#
|
||||
# Порог MIN_SAMPLE_SIZE после сужения пула набирается реже, но лестница радиусов
|
||||
# упирается в отказ редко — прод-симуляция на 246 реальных точках оценок:
|
||||
# insufficient_data 0 → 1 точка (0.4%), 800м хватает 241 точке из 246.
|
||||
#
|
||||
# bbox-фильтр (lat/lon) — сопоставимые листинги считаются ТОЛЬКО по Екатеринбургу, даже если
|
||||
# сам продукт уже скрейпит соседние города области (city-sweep): географию location_index
|
||||
# явно ограничил владелец продукта.
|
||||
|
|
@ -173,6 +219,8 @@ _MEDIAN_PPM2_LOCAL_SQL = text(
|
|||
AND price_per_m2 IS NOT NULL
|
||||
AND price_per_m2 BETWEEN CAST(:price_min AS integer) AND CAST(:price_max AS integer)
|
||||
AND (geo_precision IS DISTINCT FROM 'city')
|
||||
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
|
||||
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
|
||||
AND lat BETWEEN CAST(:bbox_south AS double precision)
|
||||
AND CAST(:bbox_north AS double precision)
|
||||
AND lon BETWEEN CAST(:bbox_west AS double precision)
|
||||
|
|
@ -196,6 +244,8 @@ _MEDIAN_PPM2_CITYWIDE_SQL = text(
|
|||
AND price_per_m2 IS NOT NULL
|
||||
AND price_per_m2 BETWEEN CAST(:price_min AS integer) AND CAST(:price_max AS integer)
|
||||
AND (geo_precision IS DISTINCT FROM 'city')
|
||||
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
|
||||
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
|
||||
AND lat BETWEEN CAST(:bbox_south AS double precision)
|
||||
AND CAST(:bbox_north AS double precision)
|
||||
AND lon BETWEEN CAST(:bbox_west AS double precision)
|
||||
|
|
@ -235,6 +285,7 @@ def _local_median_ppm2(db: Any, lat: float, lon: float, radius_m: int) -> tuple[
|
|||
"lat": lat,
|
||||
"lon": lon,
|
||||
"radius_m": radius_m,
|
||||
"fresh_days": LISTINGS_FRESH_DAYS,
|
||||
"price_min": _PRICE_PER_M2_SANITY_MIN,
|
||||
"price_max": _PRICE_PER_M2_SANITY_MAX,
|
||||
"bbox_south": _EKB_BBOX_SOUTH,
|
||||
|
|
@ -257,6 +308,7 @@ def _citywide_median_ppm2(db: Any) -> tuple[float | None, int]:
|
|||
db.execute(
|
||||
_MEDIAN_PPM2_CITYWIDE_SQL,
|
||||
{
|
||||
"fresh_days": LISTINGS_FRESH_DAYS,
|
||||
"price_min": _PRICE_PER_M2_SANITY_MIN,
|
||||
"price_max": _PRICE_PER_M2_SANITY_MAX,
|
||||
"bbox_south": _EKB_BBOX_SOUTH,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,204 @@
|
|||
-- 211_sales_vs_listings_segment_guard.sql
|
||||
-- Purpose: #2660 — «медианный торг» на витрине /sales-vs-listings считался с участием
|
||||
-- ПЕРВИЧКИ. CTE window_listings в street_sales_vs_listings() (миграция 205) читает
|
||||
-- listings без сегментного гарда #1186, поэтому в пару к ДКП-сделке вторички мог
|
||||
-- встать лот застройщика: девелоперский прайс (фиксированный, без торга) формирует
|
||||
-- показываемый пользователю процент скидки.
|
||||
--
|
||||
-- Прод-замер (2026-08-05, БД tradein):
|
||||
-- - популяция кандидатов на пейринг (окно period_months+6 = 30 мес, price_rub > 0):
|
||||
-- 93 241 строк, из них novostroyki 25 428 (27.3%), vtorichka 66 256,
|
||||
-- listing_segment IS NULL 1 557 (legacy до м.011 — считаем вторичкой);
|
||||
-- - симуляция по 20 самым «густым» (улица, комнаты) ЕКБ (2 120 сделок):
|
||||
-- до гарда 1 219 сделок получили listing-match, из них 299 (24.5%) — против
|
||||
-- новостройки; медианный торг −18.18%. После гарда: 994 матча, медиана −17.11%
|
||||
-- (+1.07 п.п. — торг перестаёт «утяжеляться» за счёт первички).
|
||||
--
|
||||
-- Что НЕ меняется и почему:
|
||||
-- - `is_active` в window_listings НЕ добавляем — осознанно (см. 067/205): функция
|
||||
-- намеренно смотрит и снятые объявления, иначе к сделке нечего подставить
|
||||
-- (объявление снимают ПОСЛЕ продажи — активные для пейринга бесполезны).
|
||||
-- - Свежесть здесь тоже не при чём по той же причине: пейринг привязан к дате
|
||||
-- сделки (window_days ± grace), а не к «сейчас».
|
||||
-- - Сигнатура функции НЕ меняется (те же 7 аргументов, что после м.205).
|
||||
-- CREATE OR REPLACE FUNCTION с ИЗМЕНЁННЫМ списком типов создал бы ВТОРУЮ
|
||||
-- перегрузку вместо замены (грабли #2627, разобраны в шапке 205) — здесь
|
||||
-- список типов побайтово тот же, значит замена in-place, оверлоада не будет.
|
||||
-- DROP FUNCTION намеренно НЕ делаем: 205 уже дропнула старую 6-арг сигнатуру,
|
||||
-- а дропать текущую 7-арг перед CREATE — лишний оконный простой для caller'а.
|
||||
--
|
||||
-- Сегментный предикат — канонический #1186 (тот же, что в _COMMON_WHERE эстиматора
|
||||
-- и в 109_asking_to_sold_ratio_segment_filter.sql):
|
||||
-- (l.listing_segment IS NULL OR l.listing_segment = 'vtorichka')
|
||||
-- NULL = legacy вторичка до миграции 011 (rosreestr/avito/yandex без сегмента), её
|
||||
-- отбрасывать нельзя — потеряли бы 1 557 валидных кандидатов.
|
||||
--
|
||||
-- ЗАВИСИМОСТИ: 205 (текущее тело + 7-арг сигнатура), listings.listing_segment (м.011).
|
||||
-- Deploy order: после 210. Идемпотентность: CREATE OR REPLACE + COMMENT ON — оба
|
||||
-- по конструкции re-run safe, ре-apply просто перезапишет то же тело.
|
||||
-- Caller (app/api/v1/trade_in.py, /sales-vs-listings) не меняется — сигнатура и
|
||||
-- RETURNS TABLE те же.
|
||||
|
||||
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 (
|
||||
-- Сделки в улице + период. Фильтр по 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-сторона, (#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). '
|
||||
'Per-street matching: address ILIKE, area ±tolerance, rooms exact, 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;
|
||||
|
|
@ -138,6 +138,65 @@ def test_median_queries_exclude_city_centroid_and_bound_bbox() -> None:
|
|||
assert "bbox_west" in sql_text and "bbox_east" in sql_text
|
||||
|
||||
|
||||
# ── #2660: свежесть + сегмент (зеркало _COMMON_WHERE эстиматора) ─────────────
|
||||
|
||||
|
||||
def test_median_queries_filter_stale_listings() -> None:
|
||||
"""#2660: `is_active` на проде НЕ означает «живо» — деактиватор протухших покрывает не
|
||||
все источники. Без предиката свежести медиана считалась по мертвецам: прод-замер по
|
||||
пулу location_index (bbox ЕКБ) — 172 984 ₽/м² по всему активному пулу против
|
||||
163 363 по свежим. Оба запроса (локальный и общегородской) должны фильтровать
|
||||
симметрично, иначе индекс = отношение двух разных популяций."""
|
||||
for sql in (lc._MEDIAN_PPM2_LOCAL_SQL, lc._MEDIAN_PPM2_CITYWIDE_SQL):
|
||||
sql_text = str(sql.text)
|
||||
assert "scraped_at > NOW() - (:fresh_days || ' days')::interval" in sql_text
|
||||
|
||||
|
||||
def test_median_queries_exclude_novostroyki() -> None:
|
||||
"""Сегментный гард #1186 — тот же канонический предикат, что в _COMMON_WHERE
|
||||
эстиматора. NULL = legacy вторичка до м.011 и должен остаться в пуле."""
|
||||
for sql in (lc._MEDIAN_PPM2_LOCAL_SQL, lc._MEDIAN_PPM2_CITYWIDE_SQL):
|
||||
sql_text = str(sql.text)
|
||||
assert "(listing_segment IS NULL OR listing_segment = 'vtorichka')" in sql_text
|
||||
|
||||
|
||||
def test_freshness_window_is_the_estimator_constant_not_a_copy() -> None:
|
||||
"""Второе определение свежести разъедется с эстиматором при первой перекалибровке —
|
||||
витрина начнёт показывать пул, отличный от того, на котором считается цена.
|
||||
|
||||
Проверяем ИСХОДНИК, а не равенство значений: CPython кэширует малые int (−5…256),
|
||||
поэтому `lc.LISTINGS_FRESH_DAYS is estimator.LISTINGS_FRESH_DAYS` проходит и для
|
||||
скопированного литерала `LISTINGS_FRESH_DAYS = 14` — то есть стережёт не то, что
|
||||
обещает. Отсутствие присваивания в модуле = константа может быть только импортом.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
from app.services import estimator
|
||||
|
||||
assert "LISTINGS_FRESH_DAYS =" not in inspect.getsource(
|
||||
lc
|
||||
), "константа скопирована в location_index — она должна ИМПОРТИРОВАТЬСЯ из estimator"
|
||||
assert lc.LISTINGS_FRESH_DAYS == estimator.LISTINGS_FRESH_DAYS
|
||||
|
||||
|
||||
def test_both_median_queries_bind_the_same_freshness_window() -> None:
|
||||
"""Свежесть должна реально долетать в оба запроса bind-параметром (а не остаться
|
||||
только в тексте SQL) и быть ОДИНАКОВОЙ — иначе локальная и городская медианы
|
||||
считаются по разным окнам, и индекс — отношение несопоставимых величин."""
|
||||
db = _FakeDB(
|
||||
[
|
||||
_FakeResult(scalar_value=0), # poi mirror empty
|
||||
_FakeResult(mapping_one={"median_ppm2": 150_000.0, "n": 4000}), # citywide
|
||||
_FakeResult(mapping_one={"median_ppm2": 165_000.0, "n": 25}), # radius[0]
|
||||
]
|
||||
)
|
||||
lc.compute_location_index(db, lat=_LAT_IN_EKB, lon=_LON_IN_EKB)
|
||||
citywide_params = db.executed[1][1]
|
||||
local_params = db.executed[2][1]
|
||||
assert citywide_params["fresh_days"] == lc.LISTINGS_FRESH_DAYS
|
||||
assert local_params["fresh_days"] == lc.LISTINGS_FRESH_DAYS
|
||||
|
||||
|
||||
# ── _fetch_nearby_poi (qualitative "что рядом" list) ─────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
205
tradein-mvp/backend/tests/test_admin_stale_counters.py
Normal file
205
tradein-mvp/backend/tests/test_admin_stale_counters.py
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
"""#2660: админ-счётчики честно разделяют «активно» и «из них не виделись N дней».
|
||||
|
||||
Почему это баг, а не косметика: `is_active` снимается только деактиватором протухших,
|
||||
а он покрывает не все источники — прод (2026-08-05) показывал cian 18 530 «активных»
|
||||
при 12 683 не виденных 14+ дней и 10 212 не виденных 30+. Именно поэтому #2574 месяц
|
||||
читалась как «всё собирается». Счётчик не прячем (иначе потеряем видимость проблемы) —
|
||||
отдаём вторым числом рядом.
|
||||
|
||||
Покрыто (db мокается, NO live network/DB):
|
||||
- GET /api/v1/admin/scraper/data-quality — stale_count per source + stale_days;
|
||||
- GET /api/v1/trade-in/cache-stats — listings_active_stale + listings_stale_days;
|
||||
- порог свежести = LISTINGS_FRESH_DAYS эстиматора (не второе определение);
|
||||
- оба запроса считают протухшее по last_seen_at И только среди is_active.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
class _FakeResult:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
rows: list[dict] | None = None,
|
||||
one: dict | None = None,
|
||||
scalar_value: Any = None,
|
||||
) -> None:
|
||||
self._rows = rows or []
|
||||
self._one = one
|
||||
self._scalar = scalar_value
|
||||
|
||||
def scalar(self) -> Any:
|
||||
return self._scalar
|
||||
|
||||
def mappings(self) -> Any:
|
||||
outer = self
|
||||
|
||||
class _Mappings:
|
||||
def all(self) -> list[dict]:
|
||||
return outer._rows
|
||||
|
||||
def one(self) -> dict:
|
||||
assert outer._one is not None
|
||||
return outer._one
|
||||
|
||||
def fetchone(self) -> dict | None:
|
||||
return outer._one
|
||||
|
||||
return _Mappings()
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
"""Session stand-in: execute() отдаёт очередь заготовленных результатов."""
|
||||
|
||||
def __init__(self, results: list[_FakeResult]) -> None:
|
||||
self._results = list(results)
|
||||
self.executed: list[tuple[Any, Any]] = []
|
||||
|
||||
def execute(self, clause: Any, params: Any = None) -> _FakeResult:
|
||||
self.executed.append((clause, params))
|
||||
return self._results.pop(0)
|
||||
|
||||
|
||||
# Одна строка coverage-выборки: source + счётчики + fill-агрегаты по всем полям.
|
||||
def _dq_row(source: str, active: int, stale: int) -> dict[str, Any]:
|
||||
from app.api.v1.admin import _DQ_LISTING_FIELDS
|
||||
|
||||
row: dict[str, Any] = {"source": source, "active_count": active, "stale_count": stale}
|
||||
for name, _expr in _DQ_LISTING_FIELDS:
|
||||
row[f"f_{name}"] = active # 100% fill — не предмет этого теста
|
||||
return row
|
||||
|
||||
|
||||
_HOUSES_ROW = {"total": 10, "validated_cnt": 5, "rating_cnt": 5, "house_type_cnt": 5}
|
||||
|
||||
|
||||
def _make_client(db: _FakeDB, router_module: str, prefix: str) -> TestClient:
|
||||
import importlib
|
||||
|
||||
from app.core.db import get_db
|
||||
|
||||
module = importlib.import_module(router_module)
|
||||
app = FastAPI()
|
||||
app.include_router(module.router, prefix=prefix)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
# ── GET /scraper/data-quality ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dq_db() -> _FakeDB:
|
||||
return _FakeDB(
|
||||
[
|
||||
_FakeResult(
|
||||
rows=[
|
||||
# Прод-пропорции 2026-08-05: cian почти весь протухший, avito живой.
|
||||
_dq_row("cian", 18_530, 12_683),
|
||||
_dq_row("avito", 4_975, 0),
|
||||
]
|
||||
),
|
||||
_FakeResult(one=_HOUSES_ROW),
|
||||
_FakeResult(scalar_value=0),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_data_quality_reports_stale_next_to_active(dq_db: _FakeDB) -> None:
|
||||
"""«Активно» остаётся как было, рядом появляется «из них не виделись N дней»."""
|
||||
client = _make_client(dq_db, "app.api.v1.admin", "/api/v1/admin")
|
||||
r = client.get("/api/v1/admin/scraper/data-quality")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
by_source = {s["source"]: s for s in body["sources"]}
|
||||
assert by_source["cian"]["active_count"] == 18_530
|
||||
assert by_source["cian"]["stale_count"] == 12_683
|
||||
# Живой источник — протухших нет, счётчик не «шумит» на здоровых данных.
|
||||
assert by_source["avito"]["active_count"] == 4_975
|
||||
assert by_source["avito"]["stale_count"] == 0
|
||||
|
||||
|
||||
def test_data_quality_exposes_the_threshold_used(dq_db: _FakeDB) -> None:
|
||||
"""Порог отдаётся в ответе — UI подписывает число, а не хардкодит своё N."""
|
||||
from app.services.estimator import LISTINGS_FRESH_DAYS
|
||||
|
||||
client = _make_client(dq_db, "app.api.v1.admin", "/api/v1/admin")
|
||||
body = client.get("/api/v1/admin/scraper/data-quality").json()
|
||||
assert body["stale_days"] == LISTINGS_FRESH_DAYS
|
||||
|
||||
|
||||
def test_data_quality_counts_stale_by_last_seen_within_active_only(dq_db: _FakeDB) -> None:
|
||||
"""Протухшее считается по last_seen_at (буквально «не виделись») и ТОЛЬКО среди
|
||||
активных — иначе счётчик смешает деактивированные строки и перестанет быть
|
||||
сопоставимым с active_count."""
|
||||
from app.services.estimator import LISTINGS_FRESH_DAYS
|
||||
|
||||
client = _make_client(dq_db, "app.api.v1.admin", "/api/v1/admin")
|
||||
client.get("/api/v1/admin/scraper/data-quality")
|
||||
clause, params = dq_db.executed[0]
|
||||
sql = str(clause)
|
||||
assert "COUNT(*) FILTER (" in sql
|
||||
assert "last_seen_at <= NOW() - (:fresh_days || ' days')::interval" in sql
|
||||
assert "WHERE is_active = true" in sql
|
||||
assert params == {"fresh_days": LISTINGS_FRESH_DAYS}
|
||||
|
||||
|
||||
# ── GET /trade-in/cache-stats ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _cache_stats_row() -> dict[str, Any]:
|
||||
return {
|
||||
"geocode_cache": 1,
|
||||
"geocode_cache_fresh": 1,
|
||||
"listings_active": 37_900,
|
||||
"listings_active_stale": 20_935,
|
||||
"listings_last_scraped": None,
|
||||
"deals": 0,
|
||||
"cad_buildings": 0,
|
||||
"house_metadata": 0,
|
||||
"estimates_total": 0,
|
||||
"avg_median_price": None,
|
||||
"repeat_address_pct": None,
|
||||
}
|
||||
|
||||
|
||||
def test_cache_stats_splits_active_and_stale() -> None:
|
||||
from app.services.estimator import LISTINGS_FRESH_DAYS
|
||||
|
||||
db = _FakeDB([_FakeResult(one=_cache_stats_row())])
|
||||
client = _make_client(db, "app.api.v1.trade_in", "/api/v1/trade-in")
|
||||
body = client.get("/api/v1/trade-in/cache-stats").json()
|
||||
assert body["listings_active"] == 37_900
|
||||
assert body["listings_active_stale"] == 20_935
|
||||
assert body["listings_stale_days"] == LISTINGS_FRESH_DAYS
|
||||
|
||||
|
||||
def test_cache_stats_counts_stale_by_last_seen_within_active_only() -> None:
|
||||
from app.services.estimator import LISTINGS_FRESH_DAYS
|
||||
|
||||
db = _FakeDB([_FakeResult(one=_cache_stats_row())])
|
||||
client = _make_client(db, "app.api.v1.trade_in", "/api/v1/trade-in")
|
||||
client.get("/api/v1/trade-in/cache-stats")
|
||||
clause, params = db.executed[0]
|
||||
sql = str(clause)
|
||||
assert "last_seen_at <= NOW() - (:fresh_days || ' days')::interval" in sql
|
||||
assert "listings_active_stale" in sql
|
||||
assert params == {"fresh_days": LISTINGS_FRESH_DAYS}
|
||||
|
||||
|
||||
def test_cache_stats_empty_db_returns_empty_dict_not_a_fake_threshold() -> None:
|
||||
"""Нет строки — отдаём {}, а не {listings_stale_days: N} с пустыми счётчиками
|
||||
(иначе UI отрисует подпись к несуществующему числу)."""
|
||||
db = _FakeDB([_FakeResult(one=None)])
|
||||
client = _make_client(db, "app.api.v1.trade_in", "/api/v1/trade-in")
|
||||
assert client.get("/api/v1/trade-in/cache-stats").json() == {}
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
"""Static guards для миграции 211 (сегментный гард #1186 в street_sales_vs_listings(), #2660).
|
||||
|
||||
Прод применяет data/sql построчно строго (ON_ERROR_STOP), полный DB-прогон требует живой
|
||||
БД — здесь фиксируем структурные инварианты:
|
||||
- транзакционность + идемпотентность (CREATE OR REPLACE, без DROP/TRUNCATE);
|
||||
- гард #1186 стоит именно в window_listings (listings-сторона), а не в window_deals
|
||||
(у deals нет listing_segment) и не в paired;
|
||||
- `is_active` НЕ появился — его отсутствие осознанно (снятые объявления и есть
|
||||
материал пейринга), см. шапку 205/211;
|
||||
- сигнатура НЕ изменилась относительно 205 — иначе CREATE OR REPLACE создаст ВТОРУЮ
|
||||
перегрузку вместо замены (грабли #2627), и caller получит старое тело;
|
||||
- city-предикаты #2583 H4 (обе стороны JOIN) на месте — не потеряны при переносе тела;
|
||||
- RETURNS TABLE не переименована — endpoint читает колонки по имени.
|
||||
"""
|
||||
|
||||
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"
|
||||
_MIGRATION_211 = _SQL_DIR / "211_sales_vs_listings_segment_guard.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_211) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _executable_sql(path: Path = _MIGRATION_211) -> str:
|
||||
"""SQL без построчных `--`-комментариев — только исполняемый код."""
|
||||
lines = []
|
||||
for raw in _sql(path).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 _function_body(path: Path = _MIGRATION_211) -> str:
|
||||
"""Тело функции между `AS $$` и `$$;`, без `--`-комментариев, нормализованное.
|
||||
|
||||
Отдельно от _executable_sql(): COMMENT ON FUNCTION — тоже исполняемый SQL, и его
|
||||
текст описывает фильтры словами (в т.ч. «is_active намеренно не фильтруется»).
|
||||
Проверки «такого предиката в запросе нет» обязаны смотреть только на тело.
|
||||
"""
|
||||
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 _function_signature(path: Path) -> str:
|
||||
"""Блок `CREATE OR REPLACE FUNCTION ...(...)` до RETURNS TABLE, нормализованный."""
|
||||
sql = _sql(path)
|
||||
start = sql.index("CREATE OR REPLACE FUNCTION street_sales_vs_listings(")
|
||||
end = sql.index("RETURNS TABLE", start)
|
||||
return _flat(sql[start:end])
|
||||
|
||||
|
||||
def test_migration_211_exists() -> None:
|
||||
assert _MIGRATION_211.exists(), f"missing migration: {_MIGRATION_211}"
|
||||
|
||||
|
||||
def test_migration_211_is_transactional() -> None:
|
||||
sql = _sql()
|
||||
assert "BEGIN;" in sql
|
||||
assert "COMMIT;" in sql
|
||||
|
||||
|
||||
def test_migration_211_no_destructive_ddl() -> None:
|
||||
"""Только CREATE OR REPLACE FUNCTION + COMMENT — таблицы не трогаем."""
|
||||
flat = _flat(_executable_sql())
|
||||
assert "drop table" not in flat
|
||||
assert "alter table" not in flat
|
||||
assert "truncate" not in flat
|
||||
assert "delete from" not in flat
|
||||
|
||||
|
||||
def test_migration_211_segment_guard_present_in_window_listings() -> None:
|
||||
"""Гард #1186 — канонический предикат, НЕ `= 'vtorichka'` без NULL-ветки
|
||||
(NULL = legacy вторичка до м.011; прод: 1 557 таких кандидатов)."""
|
||||
flat = _flat(_executable_sql())
|
||||
assert _SEGMENT_GUARD in flat
|
||||
# Гард стоит в listings-CTE, а не где-то ниже по тексту (в paired/финальном SELECT).
|
||||
window_listings_pos = flat.index("window_listings as")
|
||||
paired_pos = flat.index("paired as")
|
||||
guard_pos = flat.index(_SEGMENT_GUARD)
|
||||
assert window_listings_pos < guard_pos < paired_pos
|
||||
|
||||
|
||||
def test_migration_211_does_not_add_is_active_filter() -> None:
|
||||
"""Отсутствие is_active — ОСОЗНАННОЕ (#2660): функция намеренно смотрит и снятые
|
||||
объявления, иначе к сделке нечего подставить (объявление снимают ПОСЛЕ продажи)."""
|
||||
assert "is_active" not in _function_body()
|
||||
|
||||
|
||||
def test_migration_211_does_not_add_freshness_filter() -> None:
|
||||
"""Свежесть здесь тоже не при чём: пейринг привязан к дате СДЕЛКИ (window_days),
|
||||
а не к «сейчас». Регресс-гард против копипасты предиката из location_index."""
|
||||
body = _function_body()
|
||||
assert "l.scraped_at > now()" not in body
|
||||
assert "last_seen_at" not in body
|
||||
|
||||
|
||||
def test_migration_211_signature_identical_to_205_no_new_overload() -> None:
|
||||
"""#2627: CREATE OR REPLACE с ИЗМЕНЁННЫМ списком типов создаёт ВТОРУЮ перегрузку,
|
||||
а не замену — caller продолжит попадать в старое тело. Сигнатура 211 обязана
|
||||
совпадать с 205 побайтово (после нормализации пробелов)."""
|
||||
assert _function_signature(_MIGRATION_211) == _function_signature(_MIGRATION_205)
|
||||
|
||||
|
||||
def test_migration_211_does_not_drop_current_signature() -> None:
|
||||
"""DROP текущей 7-арг сигнатуры не нужен (замена и так in-place) и вреден —
|
||||
между DROP и CREATE caller получил бы `function does not exist`."""
|
||||
flat = _flat(_executable_sql())
|
||||
new_sig = "street_sales_vs_listings(" + ", ".join(_SIGNATURE_TYPES) + ")"
|
||||
assert f"drop function if exists {new_sig}" not in flat
|
||||
assert "drop function" not in flat
|
||||
|
||||
|
||||
def test_migration_211_exactly_one_function_definition() -> None:
|
||||
"""Ровно одно CREATE OR REPLACE — иначе последнее молча перетрёт предыдущее."""
|
||||
flat = _flat(_executable_sql())
|
||||
assert flat.count("create or replace function street_sales_vs_listings") == 1
|
||||
|
||||
|
||||
def test_migration_211_comment_targets_the_7arg_signature() -> None:
|
||||
"""COMMENT ON FUNCTION со списком типов — иначе комментарий сядет на
|
||||
несуществующий оверлоад / упадёт на ambiguity."""
|
||||
flat = _flat(_executable_sql())
|
||||
new_sig = "street_sales_vs_listings(" + ", ".join(_SIGNATURE_TYPES) + ")"
|
||||
assert f"comment on function {new_sig}" in flat
|
||||
|
||||
|
||||
def test_migration_211_keeps_city_predicates_from_205() -> None:
|
||||
"""Тело переносится целиком — city-скоуп #2583 H4 (обе стороны JOIN) не потерян."""
|
||||
flat = _flat(_executable_sql())
|
||||
assert "(p_target_city is null or lower(d.city) = lower(p_target_city))" in flat
|
||||
assert (
|
||||
"(p_target_city is null or l.city is null or lower(l.city) = lower(p_target_city))" in flat
|
||||
)
|
||||
|
||||
|
||||
def test_migration_211_return_table_shape_unchanged() -> None:
|
||||
"""RETURNS TABLE читается endpoint'ом по имени через .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_211_no_psycopg_cast_trap() -> None:
|
||||
"""Регресс-гард против copy-paste `:param::type` (в чистом .sql bind-параметров нет,
|
||||
но файл переносится в Python-тесты/скрипты)."""
|
||||
assert not re.search(r":\w+::", _sql())
|
||||
|
||||
|
||||
def test_migration_211_body_matches_205_except_the_guard() -> None:
|
||||
"""Тело — байт-в-байт копия 205 плюс ровно один новый предикат. Защищает от
|
||||
случайной потери логики при ручном переносе большого SQL-блока."""
|
||||
|
||||
def body(path: Path) -> list[str]:
|
||||
sql = _sql(path)
|
||||
start = sql.index("AS $$")
|
||||
end = sql.index("$$;", start)
|
||||
# Только исполняемые строки, без комментариев — их мы правим осознанно.
|
||||
return [
|
||||
_flat(line.split("--", 1)[0])
|
||||
for line in sql[start:end].splitlines()
|
||||
if line.split("--", 1)[0].strip()
|
||||
]
|
||||
|
||||
old_body = body(_MIGRATION_205)
|
||||
new_body = body(_MIGRATION_211)
|
||||
added = [line for line in new_body if line not in old_body]
|
||||
removed = [line for line in old_body if line not in new_body]
|
||||
assert added == ["and " + _SEGMENT_GUARD], added
|
||||
assert removed == [], removed
|
||||
|
|
@ -357,10 +357,12 @@ def _make_dq_db_mock() -> MagicMock:
|
|||
"""Мок db-сессии для GET /scraper/data-quality."""
|
||||
db = MagicMock()
|
||||
|
||||
# listings rows: avito=1000 active, cian=500 active
|
||||
# listings rows: avito=1000 active (все свежие), cian=500 active / 300 протухших
|
||||
# (#2660: stale_count идёт рядом с active_count — «активно» ≠ «живо»).
|
||||
avito_row = {
|
||||
"source": "avito",
|
||||
"active_count": 1000,
|
||||
"stale_count": 0,
|
||||
"f_description": 900,
|
||||
"f_photo_urls": 980,
|
||||
"f_address": 1000,
|
||||
|
|
@ -375,6 +377,7 @@ def _make_dq_db_mock() -> MagicMock:
|
|||
cian_row = {
|
||||
"source": "cian",
|
||||
"active_count": 500,
|
||||
"stale_count": 300,
|
||||
"f_description": 450,
|
||||
"f_photo_urls": 490,
|
||||
"f_address": 500,
|
||||
|
|
@ -435,6 +438,7 @@ def test_data_quality_shape(client: TestClient) -> None:
|
|||
|
||||
avito = sources["avito"]
|
||||
assert avito["active_count"] == 1000
|
||||
assert avito["stale_count"] == 0 # #2660
|
||||
fields = avito["fields"]
|
||||
assert 0.0 <= fields["description"] <= 100.0
|
||||
assert fields["description"] == pytest.approx(90.0, abs=0.1)
|
||||
|
|
@ -443,6 +447,7 @@ def test_data_quality_shape(client: TestClient) -> None:
|
|||
|
||||
cian = sources["cian"]
|
||||
assert cian["active_count"] == 500
|
||||
assert cian["stale_count"] == 300 # #2660: 60% «активных» на деле не виделись
|
||||
assert cian["fields"]["ceiling_height"] == pytest.approx(70.0, abs=0.1)
|
||||
assert cian["fields"]["ceiling_height_m"] == pytest.approx(0.0, abs=0.1)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue