diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py
index 9eab49fe..aa251ffe 100644
--- a/tradein-mvp/backend/app/api/v1/admin.py
+++ b/tradein-mvp/backend/app/api/v1/admin.py
@@ -2230,7 +2230,10 @@ class HouseIMVBackfillRequest(BaseModel):
)
only_status: str = Field(
default="pending",
- description="Обрабатывать дома с этим imv_status. 'transient_error' — retry.",
+ description=(
+ "Обрабатывать дома с этим imv_status. По умолчанию 'pending' + автоповтор "
+ "'transient_error' на половине пакета; явное значение = только этот статус."
+ ),
)
house_id: int | None = Field(
default=None,
@@ -2267,7 +2270,12 @@ async def scrape_house_imv_backfill(
batch_size: сколько домов обработать за запуск (default 50).
request_delay_sec: пауза между IMV-вызовами (default 5s). ВАЖНО: Avito IMV
реагирует на частые запросы с datacenter-IP. Не снижать < 3s.
- only_status: по умолчанию 'pending'. Для retry failed — 'transient_error'.
+ only_status: по умолчанию 'pending' — и тогда половина пакета сама уходит на
+ повтор домов в 'transient_error' с непотраченным лимитом попыток (#2674:
+ раньше повтор существовал только как этот параметр, и за 41 прогон его
+ не передали ни разу — 1390 домов застряли навсегда). Явное значение
+ отключает автоповтор и обрабатывает РОВНО указанный статус, включая
+ дома, исчерпавшие лимит (imv_transient_attempts >= 3).
house_id: обработать один дом (debug).
Примечание по прокси: Avito IMV использует собственную curl_cffi-сессию.
diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py
index 9030fd88..c087e656 100644
--- a/tradein-mvp/backend/app/core/config.py
+++ b/tradein-mvp/backend/app/core/config.py
@@ -613,9 +613,13 @@ class Settings(BaseSettings):
cian_valuation_max_rub: float = 500_000_000
# ── #audit-5: data-age guards ─────────────────────────────────────────────
- # sber_index_max_age_days: максимальный допустимый возраст последнего месяца
- # СберИндекс-серии (дней). Если latest месяц старее — логируем warning.
- sber_index_max_age_days: int = 35
+ # #2846: sber_index_max_age_days УДАЛЁН (был 35). Порог недостижим по построению
+ # (period_month — метка первого числа + лаг публикации источника ⇒ пол 46 суток),
+ # guard был истинным 100% времени. Свежесть СберИндекса теперь считает ровно одно
+ # место — tasks/sber_freshness_monitor, и считает по отставанию ЗАГРУЗКИ, а порог
+ # берёт из такта самой загрузки (scrape_schedules.default_params.interval_days),
+ # так что второму порогу тут больше неоткуда взяться и не с чем разъезжаться.
+ # extra="ignore" в model_config защищает от startup-краха на leftover env var.
# avito_imv_thin_market_threshold: если market_count < порога — IMV-оценка
# на тонком рынке (thin_market=True в AvitoImvSummary) + warning.
avito_imv_thin_market_threshold: int = 10
@@ -643,6 +647,23 @@ class Settings(BaseSettings):
# индексы РФ лежат в [0.6, 1.8]; за этими порогами — артефакт, а не сигнал.
estimate_quarter_index_factor_min: float = 0.6
estimate_quarter_index_factor_max: float = 1.8
+ # Квартал ЦЕЛИ по её координатам (ближайшее здание в cad_buildings_local),
+ # когда dadata.house_cadnum пуст — а он пуст в 15 из 15 применений на проде.
+ # ВЫКЛЮЧЕН по умолчанию (ENV: ESTIMATE_QUARTER_FROM_COORDS_ENABLED).
+ #
+ # Почему dormant. Точность самого резолва измерена (2544 дома ЕКБ, где кадастр
+ # известен независимо — ответ DaData на адрес, не KNN-подсказка): 92.1% на 25 м,
+ # 79.8% на 50 м. То есть механизм работоспособен. Но ЭФФЕКТ поправки на точность
+ # цены НЕ измерен: бэктест-гейт реплеит фикстуру с target_house_cadnum=None и
+ # координатный резолв не проходит. Точность резолва ≠ польза поправки, а тракт
+ # денежный — поэтому включение отдельным решением, после замера.
+ #
+ # Критерий приёмки (записан ДО факта, 2026-08-12): перезахватить фикстуру с
+ # заполненным координатным кварталом и получить overall MAPE не хуже 12.63 И
+ # сегмент эконом не хуже 14.20 при доле затронутых сделок >= 5%. Если к
+ # 2026-09-12 замер не сделан — флаг и `_lookup_target_quarter_by_coords` удалить,
+ # а не оставлять «на вырост».
+ estimate_quarter_from_coords_enabled: bool = False
# ── Сегментная поправка эстиматора по ценовому бэнду (#2255) ──────────────
# Эстиматор систематически занижает верхние сегменты (live-бэктест n=561,
diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py
index 923a7607..2c29d640 100644
--- a/tradein-mvp/backend/app/schemas/trade_in.py
+++ b/tradein-mvp/backend/app/schemas/trade_in.py
@@ -153,7 +153,20 @@ class DkpCorridor(BaseModel):
low_ppm2: int # P10 ₽/м² по сделкам (робастный коридор)
median_ppm2: int # медиана ₽/м²
high_ppm2: int # P90 ₽/м² по сделкам (робастный коридор)
- period_months: int # окно поиска сделок
+ period_months: int # окно ПОИСКА сделок — НЕ возраст данных (см. latest_deal_date)
+ # #2846: max(deal_date) по ОТОБРАННЫМ сделкам (по тем самым, что дали low/
+ # median/high — включая city-wide widen, если сработал), НЕ по всей таблице.
+ # period_months отвечает на «где искали», а не «насколько свежи сделки»: прод
+ # 2026-08-12 — окно 12 мес, свежайшая сделка в БД I кв. 2026, и у 8.7% выборок
+ # даже она отсутствует (свежайшая — IV кв. 2025). Общий max по таблице был бы
+ # враньём в пользу свежести именно для них.
+ # Precision — КВАРТАЛ: Rosreestr open dataset пишет deal_date = первый день
+ # квартала (#1995, _date_precision_for_source). Прод-замер 2026-08-12: 96 974
+ # сделки, 9 различных deal_date, day-of-month = 1 у 100%, месяцы ровно
+ # {01,04,07,10} → метка пачки, а не дата регистрации. Отсюда и форма подписи
+ # на витрине — «по I кв. 2026», не «12.01.2026» и не «223 дня назад».
+ # None = сделки без даты (в проде не встречается) — потребитель молчит.
+ latest_deal_date: date | None = None
class PriceTrendPoint(BaseModel):
diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py
index f3a95d17..c5af066b 100644
--- a/tradein-mvp/backend/app/services/estimator.py
+++ b/tradein-mvp/backend/app/services/estimator.py
@@ -48,7 +48,6 @@ from scraper_kit.providers.cian.valuation import (
estimate_via_cian_valuation,
)
from scraper_kit.providers.yandex.valuation import (
- ValuationHouseMeta,
YandexValuationResult,
YandexValuationScraper,
)
@@ -490,6 +489,32 @@ def _repair_coefficient(repair_state: str | None) -> float:
return _REPAIR_COEF.get(repair_state, 1.0)
+# Обратный `_IMV_REPAIR_MAP`: вокабуляр Avito → наш repair_state.
+_IMV_REPAIR_MAP_REV: dict[str, str] = {v: k for k, v in _IMV_REPAIR_MAP.items() if k and v}
+
+
+def _anchor_repair_factor(anchor_renovation: str | None, target_repair: str | None) -> float:
+ """#2677: множитель, приводящий домовой IMV-якорь к базису ремонта target'а.
+
+ Домовая строка `house_imv_evaluations` запрошена у Avito с ОДНИМ ремонтом
+ (`renovation_type`, до #2675 — литерал 'cosmetic' у всех 2685 строк), а медиана
+ к моменту blend'а уже домножена на `_repair_coefficient(target_repair)`. Сравнивать
+ и смешивать их напрямую — значит класть два разных базиса на одну шкалу: у клиента
+ с «требует ремонта» медиана −6%, якорь нет, порог `median×1.15` фактически падает до
+ ×1.081, а сработавший blend возвращает половину этих −6% обратно вверх.
+
+ Это НЕ заявка на правильность самих коэффициентов (`_REPAIR_COEF` — рыночная
+ эвристика, см. предупреждение выше): множитель берётся ровно тот, что код УЖЕ
+ применил к медиане, — чинится расхождение базисов, а не калибровка.
+
+ Неизвестный/незнакомый ремонт с любой стороны → 1.0 (no-op, прежнее поведение).
+ """
+ base = _repair_coefficient(_IMV_REPAIR_MAP_REV.get(anchor_renovation or ""))
+ if base <= 0:
+ return 1.0
+ return _repair_coefficient(target_repair) / base
+
+
# ── Asking→sold correction ratio lookup (#648 Stage 3) ──────────────────────
# Таблица asking_to_sold_ratios (migration 080) хранит per-rooms коэффициент
# ratio = median(SOLD ppm²) / median(ASKING ppm²) (~0.72–0.93). Estimator
@@ -963,13 +988,13 @@ def _save_yandex_history_items(
Batch semantics: single try/except; on any failure the batch rolls back.
- #2674 (ревью): резолв дома и запись houses.has_panorama идут ДО раннего возврата по
- пустой истории. Раньше возврат стоял первым, и страница, отрисованная идеально, но
- без единого объявления в истории, до записи панорамы не доходила — на проде это
- 1519 оценок против 1360 домов с историей, ~10% страниц молча пропускались. Цена
- переноса: match_or_create_house теперь вызывается и для таких страниц (может
- СОЗДАТЬ дом). Это тот же вызов, с тем же адресом, что уже отрабатывает на
- остальных 90% — новых сущностей класс не появляется, появляется недостающая доля.
+ #2674 (ревью): резолв дома идёт ДО раннего возврата по пустой истории. Раньше
+ возврат стоял первым, и страница, отрисованная идеально, но без единого объявления
+ в истории, дома не заводила — на проде это 1519 оценок против 1360 домов с
+ историей, ~10% страниц молча пропускались. Порядок сохранён и после сноса признака
+ «панорама» (#2674 хвост, мигр. 259): остаточный эффект переноса — сам
+ match_or_create_house, то есть дом и строка house_sources для таких страниц. Это
+ тот же вызов, с тем же адресом, что уже отрабатывает на остальных 90%.
"""
# Resolve house ONCE per page. Synthetic ext_id = sha256(address)[:16]
# — stable across re-runs, distinguishes pages for different addresses.
@@ -1006,9 +1031,6 @@ def _save_yandex_history_items(
result.address,
)
- # Наблюдение о доме не зависит от того, есть ли на странице история объявлений.
- _save_yandex_house_panorama(db, house_id, result.house)
-
if not result.history_items:
return 0
@@ -1093,58 +1115,13 @@ def _save_yandex_history_items(
return 0
-# #2674: has_panorama разбирался парсером (providers/yandex/valuation.py:334), лежал в
-# HOUSE_FIELD_PRIORITY и обещался публичным контрактом market.v_houses (мигр. 154) — но
-# в houses не попадал НИ ОДНОЙ строкой кода: 0 непустых из 9366 домов на проде. Здесь —
-# единственное место, где yandex_valuation уже держит и house_id, и разобранную мету.
-#
-# ГЕЙТ ЧЕСТНОСТИ. Парсер отдаёт `bool`, а не `bool | None`: "Панорама" not in body_text
-# даёт False и когда метки правда нет, и когда страница не отрисовалась (капча, редизайн,
-# пустой ответ). Записывать такой False — снова выдать «не измеряли» за «измерили и нет».
-# Пишем только когда страница ТОЧНО отрисовалась: в мете есть год постройки или этажность
-# (обе — обязательные блоки нормальной страницы оценки). Иначе колонка остаётся NULL.
-def _save_yandex_house_panorama(
- db: Session,
- house_id: int | None,
- meta: ValuationHouseMeta,
-) -> None:
- """Пишет houses.has_panorama по разобранной мете yandex_valuation.
-
- No-op без house_id или когда страница не подтверждена как отрисованная (см. гейт
- выше). Best-effort: ошибка логируется и глотается — оценка не должна падать из-за
- справочного флага. Именно поэтому UPDATE идёт в begin_nested: сбой откатывает
- только свой SAVEPOINT и не отравляет транзакцию, в которой уже осела история.
- """
- if house_id is None:
- return
- if meta.year_built is None and meta.total_floors is None:
- logger.debug(
- "yandex_valuation: has_panorama не пишем для house_id=%s — "
- "страница не подтверждена (нет ни года, ни этажности)",
- house_id,
- )
- return
- try:
- with db.begin_nested():
- db.execute(
- text(
- """
- UPDATE houses
- SET has_panorama = CAST(:panorama AS boolean)
- WHERE id = CAST(:hid AS bigint)
- AND has_panorama IS DISTINCT FROM CAST(:panorama AS boolean)
- """
- ),
- {"hid": house_id, "panorama": meta.has_panorama},
- )
- db.commit()
- except Exception as e:
- logger.warning(
- "yandex_valuation: has_panorama save failed for house_id=%s (continuing): %s",
- house_id,
- e,
- )
- db.rollback()
+# #2674 (хвост): _save_yandex_house_panorama удалена вместе с одноимённой колонкой houses
+# (мигр. 259). Гейт «пишем только с отрисованной страницы» работал как задумано — и не мог
+# ничего спасти: измерять было нечего. На проде 0 true из 1536 страниц yandex_valuation за
+# 24.05–12.08.2026, а живая проверка боевым трактом 13.08.2026 (три адреса ЕКБ, HTTP 200,
+# полный HTML) не нашла слова «панорам» ни в одном документе — ни в тексте, ни в атрибутах,
+# ни в JSON-стейте. Признака на площадке нет, а поиск подстроки по тексту, где такого слова
+# не бывает, гарантирует False механически: о доме этот ноль не говорил ничего.
# ── #651: IMV / Yandex blend (killer accuracy fix) ─────────────────────────────
@@ -1160,14 +1137,39 @@ def _fetch_house_imv_anchor(
"""Достаёт РЕАЛЬНУЮ Avito IMV-оценку target-дома из `house_imv_evaluations`.
В отличие от `avito_imv_evaluations` (keyed estimate_id — пустая, on-demand
- скрейп), `house_imv_evaluations` популирована (~2951 домов, fresh) и keyed по
- house_id. Резолвим строку: WHERE house_id = target_house_id, предпочитаем
- запись с ближайшими rooms+area (минимизируем |Δrooms|*10 + |Δarea%|), иначе
- самую свежую (fetched_at DESC). Best-effort: None при любой ошибке / отсутствии
- house_id / пустой таблице — estimator продолжает на гео-tier'ах (no regress).
+ скрейп), `house_imv_evaluations` keyed по house_id. Резолвим строку:
+ WHERE house_id = target_house_id + band-guard по rooms/area. Best-effort:
+ None при любой ошибке / отсутствии house_id / пустой таблице — estimator
+ продолжает на гео-tier'ах (no regress).
+
+ СОСТОЯНИЕ ТАБЛИЦЫ, замер 2026-08-12 (прежний текст обещал «популирована
+ ~2951 домов, fresh» — неправда по обоим пунктам, #2674):
+ 2680 строк, одна на дом (house_imv_eval_house_uniq_idx UNIQUE(house_id) —
+ поэтому ORDER BY ниже выбирает из ≤1 кандидата, а не ранжирует). 2633 из
+ них (98.2%) старше 40 суток, 2366 — один прогон 2026-05-24; свежих, после
+ фикса параметров 2026-08-06, всего 47.
+ Дореформенные 2633 спрошены у Авито с ЛИТЕРАЛОМ renovation_type='cosmetic'
+ и с дефолтом house_type='panel' при нераспознанном типе: мода ремонта по
+ объявлениям того же дома совпала лишь у 972 (36.9%), разошлась у 1217
+ (46.2%), у 444 моды нет; тип дома разошёлся у 74 из 2633 (46 раз ушла
+ «панель» вместо «монолита»).
+
+ ЧТО С ЭТИМ РЕШЕНО (замер на проде, 1081 персистированная оценка):
+ · Гейт «не брать дореформенные строки» — ОТКЛОНЁН: band-совместимая строка
+ находится у 224 оценок, и все 224 дореформенные. Гейт снял бы якорь у
+ 224 из 224, то есть просто выключил бы механизм.
+ · Массовая переоценка — не чинит корень и упирается в очередь: 1628 из
+ 2633 домов уже в imv_status='ok', а батч берёт только pending/
+ transient_error, где сейчас 7144 дома при темпе ~25/прогон.
+ · Корень — не свежесть, а разные базисы ремонта; починено в #2677
+ (`_anchor_repair_factor`), см. ниже.
Returns dict {recommended_price, lower_price, higher_price, market_count,
- rooms, area_m2} или None.
+ rooms, area_m2, renovation_type} или None.
+
+ #2677: renovation_type — ремонт, С КОТОРЫМ строка была запрошена у Avito.
+ Без него якорь нельзя привести к базису ремонта оцениваемой квартиры
+ (см. `_anchor_repair_factor`), и правка молча выродилась бы в no-op.
"""
if target_house_id is None:
return None
@@ -1177,7 +1179,7 @@ def _fetch_house_imv_anchor(
text(
"""
SELECT recommended_price, lower_price, higher_price,
- market_count, rooms, area_m2
+ market_count, rooms, area_m2, renovation_type
FROM house_imv_evaluations
WHERE house_id = CAST(:hid AS bigint)
AND recommended_price > 0
@@ -1359,6 +1361,80 @@ def _lookup_quarter_indexes(
return {str(row["quarter_cad_number"]): float(row["price_index"]) for row in rows}
+# Порог KNN для квартала ЦЕЛИ. 50 м (как у geo-nearest матчинга аналогов,
+# tasks/cadastral_geo_match.DEFAULT_THRESHOLD_M) замером НЕ подтвердились —
+# точность проверена на 2398 домах ЕКБ, где квартал известен независимо (кадастр
+# от DaData по адресу, а не KNN-подсказка), 2026-08-12:
+# порог ответ выдан чужой квартал точность покрытие
+# 15 м 475 26 94.5% 19.8%
+# 25 м 748 59 92.1% 31.2%
+# 50 м 1341 271 79.8% 55.9%
+# 100 м 2090 644 69.2% 87.2%
+# На 50 м каждый пятый ответ — чужой квартал, а промах стоит 1.111× по медиане
+# и 1.447× в p90 (отношение индексов чужого и своего квартала). Берём 25 м:
+# точность 92%, и это ровно тот компромисс, который видно в замере, а не
+# заимствованная у соседнего модуля константа.
+_TARGET_QUARTER_RADIUS_M = 25.0
+# Градусные ворота под GIST-индекс (планарные градусы, надмножество круга 50 м на
+# широте ЕКБ); точную дистанцию решает ST_DistanceSphere ниже.
+_TARGET_QUARTER_DEG_GATE = 0.0015
+
+
+def _lookup_target_quarter_by_coords(
+ db: Session,
+ lat: float | None,
+ lon: float | None,
+) -> str | None:
+ """Кадастровый квартал ЦЕЛИ по её собственным координатам (ближайшее здание ≤50 м).
+
+ Зачем: `dadata.house_cadnum` пуст в 15 из 15 применений квартального индекса на
+ проде (замер 2026-08-12) — то есть свой кадастр цели известен почти никогда.
+ Тот же geo-nearest приём уже наполняет `listings.building_cadastral_number`
+ (tasks/cadastral_geo_match) — здесь он применён к координатам САМОЙ цели, а не
+ к координатам аналога.
+
+ Это ПОДСКАЗКА, не идентичность здания (#2674): для квартального индекса нужен
+ только 3-й компонент кадастрового номера (квартал), а он у соседних зданий
+ одного двора совпадает. Возвращаем сразу квартал (`AA:BB:CCCCCCC`), а не
+ полный кадастр, чтобы вызывающий код не принял его за кадастр дома.
+
+ None, если ЕКБ-зеркало `cad_buildings_local` пусто/недоступно, координат нет
+ или ближайшее здание дальше _TARGET_QUARTER_RADIUS_M.
+ """
+ if lat is None or lon is None:
+ return None
+ try:
+ row = db.execute(
+ text(
+ """
+ WITH p AS (
+ SELECT ST_SetSRID(
+ ST_MakePoint(CAST(:lon AS double precision),
+ CAST(:lat AS double precision)), 4326) AS g
+ )
+ SELECT cb.cad_num
+ FROM cad_buildings_local cb, p
+ WHERE ST_DWithin(cb.geom, p.g, CAST(:deg AS double precision))
+ AND ST_DistanceSphere(cb.geom, p.g) <= CAST(:radius AS double precision)
+ ORDER BY cb.geom <-> p.g
+ LIMIT 1
+ """
+ ),
+ {
+ "lat": lat,
+ "lon": lon,
+ "deg": _TARGET_QUARTER_DEG_GATE,
+ "radius": _TARGET_QUARTER_RADIUS_M,
+ },
+ ).first()
+ except Exception as exc:
+ logger.warning("target quarter by coords lookup failed (graceful, no-op): %s", exc)
+ return None
+ if row is None:
+ return None
+ return _quarter_from_cadastre(str(row[0]))
+
+
def _apply_quarter_index(
*,
base_median_ppm2: float,
@@ -1493,7 +1569,16 @@ def _load_sber_index_series(db: Session, *, region: str) -> dict[date, float]:
"""#794: monthly {period_month: index_value} for region from sber_price_index.
Tries SBER_COEFF_DASHBOARDS in order; returns first non-empty series. {} on any error.
- #audit-5a: если latest месяц серии старее sber_index_max_age_days → warning.
+
+ #2846: per-estimate guard свежести отсюда УБРАН. Он сравнивал возраст latest
+ периода с settings.sber_index_max_age_days=35, а такой возраст недостижим по
+ построению: period_month — метка ПЕРВОГО числа месяца (≥30 суток уже на
+ закрытии месяца) плюс лаг публикации источника; на проде за 31 сутки прямых
+ наблюдений возраст не опускался ниже 46. Guard был истинным 100% времени —
+ нулевой сигнал в per-estimate логе, который вдобавок не долетал до GlitchTip
+ (event_level=ERROR). Свежесть теперь мерит ОДНО место — tasks/sber_freshness_monitor,
+ и мерит отставание ЗАГРУЗКИ (последний полный прогон vs её собственный такт),
+ а не календарь.
"""
for dash in SBER_COEFF_DASHBOARDS:
try:
@@ -1521,21 +1606,6 @@ def _load_sber_index_series(db: Session, *, region: str) -> dict[date, float]:
series = {r["period_month"]: float(r["index_value_rub_m2"]) for r in rows}
if not series:
continue
- # #audit-5a: data-age guard — предупреждаем о stale СберИндексе.
- latest = max(series)
- today = datetime.now(tz=UTC).date()
- age_days = (today - latest).days
- if age_days > settings.sber_index_max_age_days:
- logger.warning(
- "sber_index stale #audit-5a: latest=%s age=%d days"
- " (> sber_index_max_age_days=%d) region=%s dash=%s"
- " — time-adjustment may be outdated",
- latest.isoformat(),
- age_days,
- settings.sber_index_max_age_days,
- region,
- dash,
- )
return series
return {}
@@ -1700,6 +1770,10 @@ def _fetch_dkp_corridor(
series = _load_sber_index_series(db, region=SBER_TIME_ADJUST_REGION)
adjusted: list[float] = []
factors_applied: list[float] = []
+ # #2846: возраст выборки. Копится по ТЕМ ЖЕ строкам, что попали в adjusted —
+ # `continue` выше выкидывает и дату тоже, иначе сделка без ppm² подняла бы
+ # заявленную свежесть коридора, не участвуя в его границах.
+ latest_deal: date | None = None
for r in rows:
ppm2 = r["price_per_m2"]
if not ppm2:
@@ -1711,6 +1785,8 @@ def _fetch_dkp_corridor(
factor = _sber_time_factor(series, deal_month)
adjusted.append(float(ppm2) * factor)
factors_applied.append(factor)
+ if dd is not None and (latest_deal is None or dd > latest_deal):
+ latest_deal = dd
ppm2_values = sorted(adjusted)
# #oblast-D widen: a single street in a small non-EKB town can easily have
@@ -1763,6 +1839,7 @@ def _fetch_dkp_corridor(
city_rows = []
city_adjusted: list[float] = []
+ city_latest: date | None = None
for r in city_rows:
ppm2 = r["price_per_m2"]
if not ppm2:
@@ -1773,6 +1850,8 @@ def _fetch_dkp_corridor(
deal_month = date(dd.year, dd.month, 1)
factor = _sber_time_factor(series, deal_month)
city_adjusted.append(float(ppm2) * factor)
+ if dd is not None and (city_latest is None or dd > city_latest):
+ city_latest = dd
if len(city_adjusted) > len(ppm2_values):
logger.info(
@@ -1783,6 +1862,10 @@ def _fetch_dkp_corridor(
city,
)
ppm2_values = sorted(city_adjusted)
+ # #2846: коридор теперь описывает city-выборку — и возраст обязан
+ # переехать вместе с числами, иначе подпись осталась бы от street-
+ # выборки, которую на витрине уже никто не видит.
+ latest_deal = city_latest
if not ppm2_values:
return None
@@ -1805,6 +1888,7 @@ def _fetch_dkp_corridor(
"median_ppm2": int(_percentile(ppm2_values, 0.5)),
"high_ppm2": int(_percentile(ppm2_values, 0.90)),
"period_months": period_months,
+ "latest_deal_date": latest_deal,
}
@@ -2695,6 +2779,9 @@ def _price_from_inputs(
ratio_resolver: Callable[[float | None], tuple[float | None, str | None]],
quarter_index_lookup: Callable[[str], tuple[float, int] | None],
quarter_indexes_lookup: Callable[[list[str]], dict[str, float]],
+ # Кадастр ЦЕЛИ (DaData) ИЛИ уже снятый по её координатам квартал — используется
+ # ровно в одном месте, блок #764, и только ради 3-го компонента (квартала).
+ # Кадастр аналога сюда подставлять нельзя: это квартал соседа, а не цели.
target_house_cadnum: str | None,
dadata_coarse: bool,
geo: GeocodeResult,
@@ -3058,6 +3145,7 @@ def _price_from_inputs(
anchor_total: int | None = None
anchor_higher: int | None = None
anchor_label: str | None = None
+ anchor_repair_note: str = ""
if imv_anchor is not None and imv_anchor.get("recommended_price"):
anchor_total = int(imv_anchor["recommended_price"])
anchor_higher = (
@@ -3076,6 +3164,22 @@ def _price_from_inputs(
_imv_mc is not None and _imv_mc < settings.avito_imv_thin_market_threshold
),
)
+ # #2677: карточка выше показывает СЫРУЮ оценку Avito (это чужое число, мы его
+ # не правим), а в blend уходит якорь, приведённый к базису ремонта target'а —
+ # тому же, в котором median_price уже посчитан выше (repair_coef).
+ _rep_k = _anchor_repair_factor(imv_anchor.get("renovation_type"), repair_state)
+ if _rep_k != 1.0:
+ anchor_total = round(anchor_total * _rep_k)
+ anchor_higher = round(anchor_higher * _rep_k) if anchor_higher else None
+ anchor_repair_note = ", приведённой к состоянию ремонта квартиры"
+ logger.info(
+ "imv_anchor repair-basis #2677: renovation=%s target_repair=%s k=%.3f"
+ " → anchor %d",
+ imv_anchor.get("renovation_type"),
+ repair_state,
+ _rep_k,
+ anchor_total,
+ )
elif imv_eval is not None and imv_eval.recommended_price:
anchor_total = int(imv_eval.recommended_price)
anchor_higher = int(imv_eval.higher_price) if imv_eval.higher_price else None
@@ -3125,8 +3229,8 @@ def _price_from_inputs(
median_price = new_median
median_ppm2 = new_ppm2
explanation = (explanation or "") + (
- f" Оценка скорректирована по {anchor_label} "
- f"({anchor_used / 1_000_000:.1f} млн ₽)."
+ f" Оценка скорректирована по {anchor_label}"
+ f"{anchor_repair_note} ({anchor_used / 1_000_000:.1f} млн ₽)."
)
sources_used_pre = sorted(set(sources_used_pre) | {"avito_imv"})
# Диапазон расширяем даже если медиану не двигали.
@@ -3164,13 +3268,16 @@ def _price_from_inputs(
and median_price > 0
and area_m2
):
+ # target_house_cadnum: свой кадастр цели (DaData) ИЛИ квартал, снятый по
+ # координатам цели (_lookup_target_quarter_by_coords, вызывающий код).
+ # Прежде здесь был фолбэк «взять кадастр первого попавшегося аналога» —
+ # снят: dadata.house_cadnum пуст в 15 из 15 прод-применений, то есть квартал
+ # цели ВСЕГДА приходил от соседа (замер 2026-08-12: аналог-донор в 201-4087 м
+ # от цели; индексы кварталов на такой дистанции расходятся в 1.15-1.23 раза
+ # по медиане и в 1.6-1.8 по p90 — то есть множитель на витрине был чужой).
+ # Нет своего квартала → поправки нет; факт «квартал цели неизвестен» нельзя
+ # заменить чужим кварталом, он и есть предмет поправки.
target_quarter: str | None = _quarter_from_cadastre(target_house_cadnum)
- if target_quarter is None:
- for lot in listings_clean:
- cq = _quarter_from_cadastre(lot.get("building_cadastral_number"))
- if cq is not None:
- target_quarter = cq
- break
if target_quarter is not None:
qindex_result = quarter_index_lookup(target_quarter)
@@ -4240,6 +4347,22 @@ async def estimate_quality(
# ── Coarse-geo signals ────────────────────────────────────────────────────
dadata_coarse = dadata is not None and dadata.qc_geo is not None and dadata.qc_geo >= 2
+ # ── #764: квартал ЦЕЛИ для квартального индекса ──────────────────────────
+ # Свой кадастр (DaData) — приоритет; пусто (15 из 15 применений на проде) →
+ # квартал по СОБСТВЕННЫМ координатам цели, ЕСЛИ включён флаг. Кадастр аналога
+ # сюда больше не попадает вообще (см. блок #764 в _price_from_inputs).
+ # Флаг dormant: точность резолва измерена (92% на 25 м), эффект поправки на
+ # точность ЦЕНЫ — нет (бэктест-фикстура его не проходит). См. config.
+ target_quarter_cadnum: str | None = dadata.house_cadnum if dadata else None
+ if (
+ settings.estimate_quarter_from_coords_enabled
+ and not target_quarter_cadnum
+ and geo is not None
+ ):
+ target_quarter_cadnum = await asyncio.to_thread(
+ _lookup_target_quarter_by_coords, db, geo.lat, geo.lon
+ )
+
# ── DB-callable wrappers injected into pure pricing ───────────────────────
def _ratio_resolver(
appm2: float | None,
@@ -4284,7 +4407,7 @@ async def estimate_quality(
ratio_resolver=_ratio_resolver,
quarter_index_lookup=_qi_lookup,
quarter_indexes_lookup=_qis_lookup,
- target_house_cadnum=dadata.house_cadnum if dadata else None,
+ target_house_cadnum=target_quarter_cadnum,
dadata_coarse=dadata_coarse,
geo=geo,
dadata_qc_geo=dadata.qc_geo if dadata else None,
diff --git a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py
index a89eadf6..8e02aae4 100644
--- a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py
+++ b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py
@@ -1516,11 +1516,41 @@ def _examples_rows(lots: list[AnalogLot]) -> str:
# ── Page 3: Deals ────────────────────────────────────────────────────────────
+_ROMAN_QUARTER = ("I", "II", "III", "IV")
+
+
+def deals_as_of_label(estimate: AggregatedEstimate) -> str | None:
+ """#2846: «по I кв. 2026» — до какого момента доходят ПОКАЗАННЫЕ сделки.
+
+ Раньше страница печатала «Период сделок: 08.2025 – 08.2026» — окно ПОИСКА,
+ посчитанное как `today - period_months*30 … today`. Правым концом оно обещало
+ сделки сегодняшним днём, тогда как свежайшая пачка Росреестра на проде
+ (замер 2026-08-12) — I кв. 2026. Считаем по estimate.actual_deals, т.е. ровно
+ по тем сделкам, из которых страница строит диапазон и таблицу примеров.
+
+ Гранулярность — квартал: rosreestr пишет deal_date = первый день квартала
+ (#1995, ровно то, что помечает AnalogLot.date_precision == "quarter").
+ Поэтому «223 дня назад» было бы ЛОЖНОЙ точностью в сторону состаривания —
+ сделка из этой пачки могла быть и 31 марта. Источник с day-precision (пока
+ такого нет) подписывается месяцем.
+
+ None — сделок нет либо ни у одной нет даты: подписывать нечего.
+ """
+ dated = [(d.listing_date, d.date_precision) for d in estimate.actual_deals if d.listing_date]
+ if not dated:
+ return None
+ newest, precision = max(dated, key=lambda p: p[0])
+ if precision == "day":
+ return f"по {newest.strftime('%m.%Y')}"
+ return f"по {_ROMAN_QUARTER[(newest.month - 1) // 3]} кв. {newest.year}"
+
def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> str: # type: ignore[no-untyped-def,type-arg]
n_deals = len(estimate.actual_deals)
- today = dt.date.today()
- period_start = today - dt.timedelta(days=estimate.period_months * 30)
+ # #2846: «Период сделок» показывал окно поиска правым концом = сегодня.
+ # Реальная граница — as-of по показанным сделкам; окна поиска на странице
+ # больше нет (оно ничего не говорило о данных). None → строку не печатаем.
+ deals_as_of = deals_as_of_label(estimate)
# Баннер дисконта ссылается на РЕАЛЬНЫЙ рассчитанный дисконт запрос→продажа
# (тот же _discount_pct, что chip «−N%» на обложке), а не хардкод «10–18%»,
@@ -1597,9 +1627,9 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
| Количество сделок по аналогичном объектам |
{_mono(f"{n_deals} шт.")} |
- | Период сделок |
-
- {_mono(f"{period_start.strftime('%m.%Y')} – {today.strftime('%m.%Y')}")} |
+ {f'''| Сделки |
+ {_mono(deals_as_of)} |
'''
+ if deals_as_of else ""}
Источники данных
diff --git a/tradein-mvp/backend/app/services/geocoder.py b/tradein-mvp/backend/app/services/geocoder.py
index f3cdb12e..7785116a 100644
--- a/tradein-mvp/backend/app/services/geocoder.py
+++ b/tradein-mvp/backend/app/services/geocoder.py
@@ -372,6 +372,134 @@ def _names_unrecognized_locality(address: str) -> bool:
return bool(_LOCALITY_MARKER_RE.search(normalized))
+# ── Постфактум-инвариант подмены города (#2590) ──────────────────────────────
+# Гейты выше (#2582/#2589) стоят НА ВХОДЕ и решают, пускать ли ЕКБ-only тиры.
+# Внешние провайдеры ими не покрыты: «реж, ленина» уходит в Nominatim/Yandex, и
+# тот, не найдя Режа, отдаёт улицу Ленина в Екатеринбурге. Отличить на входе
+# «Реж» (город) от «Малышева» (улица) без списка городов нельзя — оба «слово до
+# запятой». ПОСЛЕ ответа можно: провайдер сам пишет, какой населённый пункт он
+# использовал, и если названный топоним туда не дожил — топоним подменён.
+#
+# Инвариант (#2590): назван топоним ≠ Екатеринбург + его нет в ответе провайдера
+# + ответ лежит внутри ЕКБ ⇒ результат недостоверен. Ни одного имени города в
+# коде — только уровни РФ-адреса (страна → регион → район → НП → улица → дом) и
+# сам целевой город.
+_ADDRESS_SEGMENT_RE = re.compile(r"[,;·]")
+# Страна: сегмент выше уровня НП. Единственная константа-топоним помимо целевого
+# города — продукт РФ-only, новых значений у неё не появится (в отличие от
+# списка городов области, ради ухода от которого всё и делается).
+_COUNTRY_RE = re.compile(r"\b(?:росси[яи]|russia)\b")
+# Уровень «улица/дом»: дойдя до него, НП уже был бы назван (порядок РФ-адреса
+# big→small). Дальше идти нельзя — иначе второй уличный сегмент («малышева,
+# мопра» — перекрёсток) читается как топоним и ложно отбраковывается.
+_STREET_LEVEL_RE = re.compile(
+ r"\b(?:ул|улица|пер|переулок|пр|пр-кт|пркт|проспект|б-р|бульвар|ш|шоссе|наб|набережная"
+ r"|пл|площадь|проезд|тракт|аллея|тупик|туп|линия|кв-л|квартал|стр|строение|дом|корп"
+ r"|корпус|лит|литера|снт|сад|гск)\b"
+)
+# Уровни ВЫШЕ и НИЖЕ населённого пункта — пропускаем и идём дальше по сегментам:
+# «свердловская обл., г.о. рефтинский» (регион → НП), «мкр-н широкая речка, ул.
+# …» (район ВНУТРИ города — его провайдер в ответе обычно не повторяет).
+_REGION_LEVEL_RE = re.compile(r"\b(?:обл\.?|область|края|край|республика|респ\.?|ао)\b")
+_DISTRICT_LEVEL_RE = re.compile(r"\b(?:р-н|р-он|район|мкр|мкр-н|микрорайон|жк|жилой)\b")
+# Слова-ТИПЫ НП (не имя): «пос. Кедровка» → имя «кедровка». Тип не сравнивается
+# с ответом — провайдер пишет свой («посёлок» vs «пос.»), имя же обязано дожить.
+_LOCALITY_TYPE_WORDS = frozenset(
+ {
+ "поселок",
+ "пос",
+ "село",
+ "деревня",
+ "дер",
+ "город",
+ "гор",
+ "округ",
+ "муниципальный",
+ "городской",
+ "сельское",
+ "поселение",
+ "тер",
+ "территория",
+ "станция",
+ "пгт",
+ "рп",
+ }
+)
+_WORD_RE = re.compile(r"[а-я][а-я-]*")
+
+
+def _fold(value: str) -> str:
+ """lower + ё→е + схлопывание пробелов — общий канон для сравнения топонимов."""
+ return " ".join(value.lower().replace("ё", "е").split())
+
+
+def _claimed_locality(address: str) -> str | None:
+ """Имя населённого пункта, названное в тексте адреса, или None.
+
+ Структурно, БЕЗ перечисления городов: идём по сегментам в порядке РФ-адреса
+ (страна → регион → район → НП → улица → дом), пропускаем уровни выше/ниже
+ НП, останавливаемся на уровне улицы/дома. Первый оставшийся сегмент — имя НП.
+
+ None означает «НП не назван» — это основной трафик формы («Малышева 30»), и
+ для него инвариант не применяется вовсе.
+ """
+ for segment in _ADDRESS_SEGMENT_RE.split(_fold(address)):
+ segment = segment.strip()
+ if not segment:
+ continue
+ if any(ch.isdigit() for ch in segment) or _STREET_LEVEL_RE.search(segment):
+ return None # улица/дом: будь НП назван, он шёл бы раньше
+ if (
+ _COUNTRY_RE.search(segment)
+ or _REGION_LEVEL_RE.search(segment)
+ or _DISTRICT_LEVEL_RE.search(segment)
+ ):
+ continue
+ name = " ".join(
+ w for w in _WORD_RE.findall(segment) if w not in _LOCALITY_TYPE_WORDS and len(w) >= 3
+ )
+ if name:
+ return name
+ return None
+
+
+def _city_substituted(address: str, result: GeocodeResult) -> bool:
+ """True если провайдер подменил названный в адресе НП Екатеринбургом (#2590).
+
+ Три условия вместе:
+ 1. в адресе назван НП и это не Екатеринбург (`_claimed_locality`);
+ 2. этого имени НЕТ в адресе, который вернул провайдер — то есть топоним не
+ пережил геокодинг;
+ 3. результат лежит внутри ЕКБ: и по координатам (`EKB_BBOX_TIGHT`), и по
+ собственному ответу провайдера — он называет Екатеринбург либо не
+ называет НП вовсе (ЕКБ-only локальные реестры отдают «Улица, дом»;
+ тогда «внутри ЕКБ» подтверждают координаты).
+
+ Условие 3 и разводит подмену с посёлками в городской черте. «пос. Кедровка,
+ Советская ул., 5» → ответ «Екатеринбург, Советская улица, 5» — имя не дожило,
+ и это ПРАВДА подмена: настоящая Кедровка в 20 км от улицы Советской. А
+ корректный ответ по посёлку («Кедровка, Екатеринбург, …» — Nominatim и Yandex
+ пишут НП всегда, когда действительно его нашли) имя сохраняет и через фильтр
+ не проходит. Проверяется не география посёлка, а факт «топоним потерян».
+
+ Известный потолок: НП, чьё имя совпало с уличным токеном ответа («Ачит» ⊂
+ «М-12 Ачит-Екатеринбург», «Лесной» ⊂ «Лесной переулок»), считается дожившим —
+ пропуск, не ложная отбраковка. Обратный потолок: жилрайон ЕКБ, названный без
+ приставки («пионерский, советская»), понижается до `locality` — честная
+ деградация, координаты не теряются.
+ """
+ claimed = _claimed_locality(address)
+ if claimed is None or _EKATERINBURG_RE.search(claimed):
+ return False
+ answer = _fold(result.full_address or "")
+ if any(word in answer for word in claimed.split()):
+ return False # топоним дожил до ответа — провайдер искал там, где просили
+ if not is_within_ekb_bbox(result.lat, result.lon):
+ return False
+ answer_locality = _claimed_locality(answer)
+ return answer_locality is None or bool(_EKATERINBURG_RE.search(answer_locality))
+
+
def _ekb_local_tiers_allowed(address: str, city_hint: str | None = None) -> bool:
"""Fail-closed гейт локальных ЕКБ-тиров geocoder (`geocode()`/`suggest()`, #2582).
@@ -1287,6 +1415,42 @@ async def suggest(
# ── Public API ───────────────────────────────────────────────────────────────
async def geocode(address: str, db: Session, city_hint: str | None = None) -> GeocodeResult | None:
+ """Геокодинг с кэшем + постфактум-проверка подмены города (#2590).
+
+ Тонкая обёртка над `_geocode_resolve` (вся тировая цепочка там). Инвариант
+ применяется ОДНОЙ точкой на выходе — поэтому покрывает разом все источники,
+ включая попадание в кэш: отравленная запись, записанная до этого фикса,
+ больше не отдаётся как точная, хотя строка в `geocode_cache` не тронута
+ (обратимо: откат кода возвращает прежнее поведение, чистить БД не требуется).
+
+ Сработал инвариант → `confidence="locality"` + `city_ambiguous=True`.
+ `locality` — не косметика: `estimator._geocode_is_coarse` уже трактует его
+ как «геокодер дошёл только до центра НП» и (а) включает #693 coarse-downgrade
+ оценки, (б) через `tasks.geocode_missing` проставляет листингу
+ `geo_precision='city'`, а этот признак исключает листинг из пула аналогов
+ (`estimator`/`location_index`: `geo_precision IS DISTINCT FROM 'city'`).
+ То есть объявление, уехавшее координатами в чужой город, перестаёт тянуть
+ за собой чужие оценки. Координаты НЕ выбрасываются — деградация честная и
+ видимая, а не отказ.
+ """
+ result = await _geocode_resolve(address, db, city_hint)
+ if result is None or not _city_substituted(address, result):
+ return result
+ logger.warning(
+ "geocode city substitution (#2590): %r → %r (%.5f, %.5f) provider=%s — "
+ "названный НП не дожил до ответа, результат внутри ЕКБ; confidence→locality",
+ address[:80],
+ (result.full_address or "")[:80],
+ result.lat,
+ result.lon,
+ result.provider,
+ )
+ return replace(result, confidence="locality", city_ambiguous=True)
+
+
+async def _geocode_resolve(
+ address: str, db: Session, city_hint: str | None = None
+) -> GeocodeResult | None:
"""Геокодинг с кэшем. Cadastral FDW → Nominatim → None.
Args:
diff --git a/tradein-mvp/backend/app/services/house_imv_backfill.py b/tradein-mvp/backend/app/services/house_imv_backfill.py
index edb02137..7dd6f1b9 100644
--- a/tradein-mvp/backend/app/services/house_imv_backfill.py
+++ b/tradein-mvp/backend/app/services/house_imv_backfill.py
@@ -411,7 +411,8 @@ def save_imv_result(db: Session, house_id: int, params: dict, result: IMVEvaluat
UPDATE houses
SET imv_status = 'ok',
last_imv_attempt_at = NOW(),
- imv_error_reason = NULL
+ imv_error_reason = NULL,
+ imv_transient_attempts = 0
WHERE id = :hid
"""),
{"hid": house_id},
@@ -424,12 +425,19 @@ def _mark_status(
status: str,
reason: str | None = None,
) -> None:
+ # #2674: счётчик растёт ТОЛЬКО на transient_error — это «сколько раз подряд дом
+ # падал по временной причине», а не «сколько раз его трогали». no_params /
+ # no_address / not_found счётчик не двигают: они не занимают retry-слот.
db.execute(
text("""
UPDATE houses
SET imv_status = :s,
last_imv_attempt_at = NOW(),
- imv_error_reason = :r
+ imv_error_reason = :r,
+ imv_transient_attempts = CASE
+ WHEN :s = 'transient_error' THEN imv_transient_attempts + 1
+ ELSE imv_transient_attempts
+ END
WHERE id = :hid
"""),
{"hid": house_id, "s": status, "r": reason},
@@ -443,6 +451,104 @@ _IMVStatus = Literal[
"ok", "no_params", "no_address", "not_found", "auth_error", "transient", "error"
]
+# #2674: сколько раз подряд дом может упасть в transient_error, прежде чем
+# перестанет занимать retry-слот. Число из замера: после починки сайдкара (04.08)
+# доля отказов на попытку — 2/27 и 3/25 (прогоны 3708/3467), т.е. ~10%. На 1039
+# застрявших это ~104 повторных отказа на первом проходе, ~10 на втором, ~1 на
+# третьем. Порог 3 стоит максимум ~115 слотов ВСЕГО (≈2 прогона) и гарантирует,
+# что дом со СВОЕЙ (не инфраструктурной) причиной не крутится в пакете вечно.
+# Исчерпавшие лимит не исчезают из наблюдаемости: они остаются imv_status=
+# 'transient_error' и считаются как
+# WHERE imv_status='transient_error' AND imv_transient_attempts >= 3.
+_MAX_TRANSIENT_ATTEMPTS = 3
+
+# Доля пакета под повтор transient_error. Половина — потому что остальные слоты
+# после #2674 достаются ТОЛЬКО домам, по которым реально будет запрос к площадке
+# (см. _premark_unusable): раньше из 50 слотов до площадки доходили 17 (замер
+# головы очереди на 12.08), так что pending на половине пакета всё равно идёт
+# быстрее, чем на целом до правки.
+_RETRY_SLOTS_SHARE = 0.5
+
+# Дом без пригодных параметров backfill всё равно пометит no_params — но только
+# заплатив слотом пакета и паузой request_delay_sec. Тот же вердикт берётся одним
+# запросом: нет ни одного объявления с rooms+area (pick_lot_params вернёт {}) ИЛИ
+# не из чего взять house_type (_map_house_type вернёт None → «unknown house_type»).
+# Причины пишем ТЕМИ ЖЕ строками, что и поштучный путь, — старые разрезы по
+# imv_error_reason продолжают работать.
+# Условие сознательно УЖЕ питоновского: normalize_house_type схлопывает в None ещё
+# и нераспознанный вокабуляр ('other', 'wireframe'), который тут остаётся текстом.
+# Промахнуться можно только в безопасную сторону — пометить меньше, чем пометил бы
+# поштучный путь.
+_PREMARK_UNUSABLE_SQL = text("""
+ WITH unusable AS (
+ SELECT h.id,
+ CASE WHEN NOT EXISTS (
+ SELECT 1 FROM listings l
+ WHERE l.house_id_fk = h.id
+ AND l.rooms IS NOT NULL
+ AND l.area_m2 IS NOT NULL)
+ THEN 'no listings with rooms+area'
+ ELSE 'unknown house_type'
+ END AS reason
+ FROM houses h
+ WHERE h.imv_status = ANY(CAST(:statuses AS text[]))
+ AND h.lat IS NOT NULL
+ AND h.lon IS NOT NULL
+ AND h.address IS NOT NULL
+ AND (
+ NOT EXISTS (
+ SELECT 1 FROM listings l
+ WHERE l.house_id_fk = h.id
+ AND l.rooms IS NOT NULL
+ AND l.area_m2 IS NOT NULL)
+ OR COALESCE(
+ NULLIF(TRIM((
+ SELECT mode() WITHIN GROUP (ORDER BY l.house_type)
+ FROM listings l
+ WHERE l.house_id_fk = h.id
+ AND l.rooms IS NOT NULL
+ AND l.area_m2 IS NOT NULL)), ''),
+ NULLIF(TRIM(h.house_type), '')
+ ) IS NULL
+ )
+ )
+ UPDATE houses
+ SET imv_status = 'no_params',
+ last_imv_attempt_at = NOW(),
+ imv_error_reason = unusable.reason
+ FROM unusable
+ WHERE houses.id = unusable.id
+""")
+
+# Основная очередь: один статус, как и было (only_status — публичный параметр
+# admin-API, семантику не трогаем).
+_QUEUE_SQL = text("""
+ SELECT id, address, full_address, lat, lon
+ FROM houses
+ WHERE imv_status = :status
+ AND lat IS NOT NULL
+ AND lon IS NOT NULL
+ AND address IS NOT NULL
+ ORDER BY last_imv_attempt_at NULLS FIRST, id
+ LIMIT :batch
+""")
+
+# Retry-очередь (#2674). Отдельный запрос, а не OR к основной: у pending
+# last_imv_attempt_at всегда NULL, поэтому при общем ORDER BY ... NULLS FIRST
+# transient_error не попал бы в пакет, пока не кончится pending (по замеру
+# 12.08 — 5747 домов ≈ год). Отдельная квота = отдельный проход.
+_RETRY_QUEUE_SQL = text("""
+ SELECT id, address, full_address, lat, lon
+ FROM houses
+ WHERE imv_status = 'transient_error'
+ AND imv_transient_attempts < :max_attempts
+ AND lat IS NOT NULL
+ AND lon IS NOT NULL
+ AND address IS NOT NULL
+ ORDER BY last_imv_attempt_at NULLS FIRST, id
+ LIMIT :batch
+""")
+
@dataclass
class HouseIMVBackfillResult:
@@ -452,6 +558,23 @@ class HouseIMVBackfillResult:
errors: int = 0
duration_sec: float = field(default=0.0)
status_counts: dict[str, int] = field(default_factory=dict)
+ # #2674: сколько домов пакета пришло из retry-очереди transient_error и
+ # сколько помечено no_params до пакета (без запроса к площадке).
+ retried: int = 0
+ premarked: int = 0
+
+
+def _premark_unusable(db: Session, statuses: list[str]) -> int:
+ """Пометить no_params дома, по которым запрос к площадке невозможен. → сколько.
+
+ Не новое поведение, а тот же вердикт _process_one_house одним запросом: на
+ 12.08 в очереди 1925 таких домов из 5143 (113 без объявлений с rooms+area,
+ 1812 без house_type) — каждый занимал слот пакета и паузу, чтобы получить
+ ответ, который виден в SQL.
+ """
+ res = db.execute(_PREMARK_UNUSABLE_SQL, {"statuses": statuses})
+ db.commit()
+ return int(res.rowcount or 0)
def _beat(heartbeat: Callable[[], None] | None) -> None:
@@ -480,7 +603,11 @@ async def backfill_house_imv(
batch_size: max houses to process (ignored when house_id given).
request_delay_sec: sleep between Avito API calls (default 5s — anti-bot).
only_status: process houses with this imv_status (default 'pending').
- Use 'transient_error' to retry failures.
+ Use 'transient_error' to retry failures. При значении по умолчанию
+ часть пакета (_RETRY_SLOTS_SHARE) автоматически уходит на повтор
+ transient_error с непотраченным лимитом попыток (#2674) — явно
+ переданный only_status этот проход отключает, оператор получает
+ ровно то, что попросил, включая исчерпавшие лимит дома.
house_id: process a single specific house (debug).
heartbeat: optional callback дёргается каждые _HEARTBEAT_EVERY_N_HOUSES
домов — caller обновляет scrape_runs.heartbeat_at, чтобы reap_zombies
@@ -509,23 +636,45 @@ async def backfill_house_imv(
.all()
)
else:
- rows = (
+ # Повторный проход только на расписании (only_status по умолчанию): явный
+ # only_status от оператора — это ручной запрос ровно одного статуса.
+ retry_lane = only_status == "pending"
+
+ statuses = [only_status] + (["transient_error"] if retry_lane else [])
+ result.premarked = _premark_unusable(db, statuses)
+ if result.premarked:
+ logger.info(
+ "house_imv_backfill: %d домов помечены no_params до пакета (нет rooms+area "
+ "или house_type) — слоты пакета не потрачены",
+ result.premarked,
+ )
+
+ retry_rows: list = []
+ if retry_lane:
+ retry_rows = (
+ db.execute(
+ _RETRY_QUEUE_SQL,
+ {
+ "max_attempts": _MAX_TRANSIENT_ATTEMPTS,
+ "batch": int(batch_size * _RETRY_SLOTS_SHARE),
+ },
+ )
+ .mappings()
+ .all()
+ )
+ result.retried = len(retry_rows)
+
+ # Недобор retry-очереди (она кончится раньше pending: 1039 против 3218 на
+ # 12.08) возвращается pending — пакет не простаивает.
+ fresh_rows = (
db.execute(
- text("""
- SELECT id, address, full_address, lat, lon
- FROM houses
- WHERE imv_status = :status
- AND lat IS NOT NULL
- AND lon IS NOT NULL
- AND address IS NOT NULL
- ORDER BY last_imv_attempt_at NULLS FIRST, id
- LIMIT :batch
- """),
- {"status": only_status, "batch": batch_size},
+ _QUEUE_SQL,
+ {"status": only_status, "batch": max(batch_size - result.retried, 0)},
)
.mappings()
.all()
)
+ rows = list(fresh_rows) + list(retry_rows)
result.checked = len(rows)
if not rows:
@@ -534,9 +683,11 @@ async def backfill_house_imv(
return result
logger.info(
- "house_imv_backfill: %d houses (status=%r delay=%.1fs)",
+ "house_imv_backfill: %d houses (status=%r retry=%d premarked=%d delay=%.1fs)",
result.checked,
only_status,
+ result.retried,
+ result.premarked,
request_delay_sec,
)
@@ -610,11 +761,14 @@ async def backfill_house_imv(
result.duration_sec = time.time() - t0
logger.info(
- "house_imv_backfill done: checked=%d saved=%d skipped=%d errors=%d %.1fs %s",
+ "house_imv_backfill done: checked=%d saved=%d skipped=%d errors=%d "
+ "retried=%d premarked=%d %.1fs %s",
result.checked,
result.saved,
result.skipped,
result.errors,
+ result.retried,
+ result.premarked,
result.duration_sec,
result.status_counts,
)
diff --git a/tradein-mvp/backend/app/services/matching/conflict_resolution.py b/tradein-mvp/backend/app/services/matching/conflict_resolution.py
index 89d71531..2e56f1eb 100644
--- a/tradein-mvp/backend/app/services/matching/conflict_resolution.py
+++ b/tradein-mvp/backend/app/services/matching/conflict_resolution.py
@@ -71,7 +71,11 @@ HOUSE_FIELD_PRIORITY: dict[str, list[str] | str] = {
"commission_year": ["cian_serp", "yandex_realty_nb"],
"commission_month": ["yandex_realty_nb"], # raw RU month name
"developer_name": ["cian", "yandex_realty_nb"],
- "has_panorama": ["yandex_valuation"], # Yandex 3D panorama flag
+ # #2674 (хвост): запись про «панораму» удалена вместе с колонкой (мигр. 259).
+ # В отличие от ceiling_height ниже, правило было ИСПОЛНИМО — колонка существовала,
+ # источник её писал. Разрешать было нечего: yandex_valuation отдавал False всегда,
+ # потому что слова «панорам» на странице оценки нет (0 true из 1536 страниц на
+ # проде; живая проверка боевым трактом 13.08.2026 не нашла его и в сыром HTML).
"yandex_total_listings": ["yandex_valuation"], # "N объектов" в истории
# Yandex Valuation enrichment (existing house attrs)
"has_lift": ["cian_bti", "cian_detail", "yandex_valuation"],
diff --git a/tradein-mvp/backend/app/services/product_handlers.py b/tradein-mvp/backend/app/services/product_handlers.py
index 64ba11e2..3abf3360 100644
--- a/tradein-mvp/backend/app/services/product_handlers.py
+++ b/tradein-mvp/backend/app/services/product_handlers.py
@@ -447,6 +447,12 @@ async def _job_house_imv_backfill(
# алерт — но пустая очередь при ежедневном расписании это и правда сигнал.
"total_seen": result.checked,
"new_count": result.saved,
+ # #2674: из скольких слотов пакета взяты дома на ПОВТОР (transient_error)
+ # и сколько домов ушло в no_params до пакета одним запросом. Без этих
+ # двух счётчиков в scrape_runs.counters проверить, что застрявшие
+ # действительно возвращаются в очередь, можно только по houses.
+ "retried": result.retried,
+ "premarked": result.premarked,
}
# Честный статус (#2674, тот же класс, что #2670/#2657): успех — это
# «сделали то, что собирались», а не «не поймали известное исключение».
diff --git a/tradein-mvp/backend/app/services/sber_index.py b/tradein-mvp/backend/app/services/sber_index.py
index 7a4d646c..8331b2d2 100644
--- a/tradein-mvp/backend/app/services/sber_index.py
+++ b/tradein-mvp/backend/app/services/sber_index.py
@@ -303,6 +303,18 @@ def _upsert_rows_sync(db: Session, rows_to_upsert: list[tuple[str, date, str, st
#1348: blocking psycopg work — must run via asyncio.to_thread, never directly
on the event loop. Idempotent ON CONFLICT(city, period_month, dashboard).
+
+ #2846: `fetched_at` НЕ переписывается при конфликте. Забор идёт ВСЕЙ серией
+ (limit=1000&offset=0, отсечки по периоду нет), поэтому `fetched_at = now()` в
+ DO UPDATE ставил одну и ту же метку всем строкам ряда — на проде все 639 строк
+ несли время последнего прогона, включая период 2017-01. Как признак свежести
+ колонка была пуста. Теперь она означает «когда мы ВПЕРВЫЕ увидели этот период»,
+ то есть по ней измеряется ТАКТ ПУБЛИКАЦИИ источника (min(fetched_at) по новым
+ периодам). Ретроспективу это не возвращает: у 639 уже лежащих строк метка
+ 2026-08-06 и она останется — такт публикации до этого PR невосстановим.
+ Времени последней ЗАГРУЗКИ колонка больше не хранит; оно и не нужно —
+ scrape_runs(source='sber_index_pull') хранит его точнее (с errors/upserted),
+ и именно оттуда его берёт tasks/sber_freshness_monitor.
"""
for city_label, period_month, segment, dash, value in rows_to_upsert:
db.execute(
@@ -323,8 +335,8 @@ def _upsert_rows_sync(db: Session, rows_to_upsert: list[tuple[str, date, str, st
ON CONFLICT (city, period_month, dashboard)
DO UPDATE SET
index_value_rub_m2 = EXCLUDED.index_value_rub_m2,
- segment = EXCLUDED.segment,
- fetched_at = now()
+ segment = EXCLUDED.segment
+ -- fetched_at НЕ трогаем (#2846): она = «впервые увидели период».
"""
),
{
diff --git a/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py b/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py
index 0e304651..00d91cf9 100644
--- a/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py
+++ b/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py
@@ -17,8 +17,9 @@ Both are wired together in the debug endpoint `POST /scrape/domclick/debug/detai
wiring into the production scheduled orchestrator (previously only reachable manually).
Solution: single snapshot SELECT at start (guarantees termination) + one BrowserFetcher
-per run (async context manager, source="domclick" -- dedicated residential proxy pool,
-see 173_scrape_proxies_add_domclick_affinity.sql) + cookies loaded ONCE via
+per run (async context manager, source="domclick" -- узел берётся из ОБЩЕГО пула;
+выделенного узла у Домклика больше нет, резервацию сняла миграция 253 (#2800),
+на 13.08 все четыре узла имеют provider_affinity='any') + cookies loaded ONCE via
domclick_session.load_session(db) and threaded into every fetch_detail() call.
NAMING TRAP (verified live against prod DB 2026-07-04, do NOT "fix" this anywhere):
@@ -40,9 +41,15 @@ Exception triad differs from Avito:
Статус такого прогона — 'banned' (#2674, см. runs.mark_backfill_finished):
блок это external constraint, не наш баг, но и НЕ успех — раньше здесь стоял
mark_done, и 24 из 30 прогонов с нулём обогащений назывались успешными.
- No IP-rotation/cooldown recovery step exists here
- (DomClick uses one dedicated residential proxy, not a rotating pool) -- an
- aborted run simply retries the remaining backlog next window.
+ No IP-rotation/cooldown recovery step exists here -- an aborted run simply
+ retries the remaining backlog next window.
+ УСТАРЕВШЕЕ ОБОСНОВАНИЕ, снято 13.08: здесь стояло «DomClick uses one dedicated
+ residential proxy, not a rotating pool». Это перестало быть правдой на миграции
+ 253 (#2800), снявшей резервацию узла; сегодня узлов четыре и все общие. То есть
+ отсутствие ротации больше НЕ следует из «ротировать нечего» — это просто
+ непринятое решение. Разбор цены и рисков: #2854 (блок бьёт внутри первой
+ комнатной корзины, buckets_completed=0 во ВСЕХ прогонах; свежий узел, судя по
+ длительности до блока 111-332 с, получает свой бюджет).
ОГРАНИЧЕНИЕ (#2764): диагноз scrape_runs.ban_kind этот прогон НЕ передаёт и
получает 'unknown'. Один и тот же DomClickBlockedError поднимается и на
распознанном QRATOR-маркере (площадка), и на любом сбое браузерного fetch
diff --git a/tradein-mvp/backend/app/tasks/sber_freshness_monitor.py b/tradein-mvp/backend/app/tasks/sber_freshness_monitor.py
index 487179d5..cde46a41 100644
--- a/tradein-mvp/backend/app/tasks/sber_freshness_monitor.py
+++ b/tradein-mvp/backend/app/tasks/sber_freshness_monitor.py
@@ -1,56 +1,68 @@
-"""Мониторинг свежести ДАННЫХ СберИндекса (не статуса джобы) — audit п.1.
+"""Монитор ОТСТАВАНИЯ ЗАГРУЗКИ СберИндекса (не календарного возраста периода).
-Проблема аудита: estimator._load_sber_index_series (#794/#audit-5a) применяет
-СберИндекс time-adjustment к ДКП-сделкам и лишь ЛОГИРУЕТ per-estimate warning,
-когда latest месяц серии старее settings.sber_index_max_age_days (35д). Джоба
-`sber_index_pull` крутится ежемесячно (enabled), а источник СберИндекса публикует
-данные с лагом ~1-2 месяца, поэтому `sber_price_index.period_month` дрейфит
-(на 2026-07-12 latest=2026-05-01, ~72д). Это НЕ silent failure, но staleness
-видна только в debug-подобном per-estimate warning'е, тонущем в логах оценок.
+ЧТО БЫЛО НЕ ТАК (замер на проде 2026-08-12, #2846).
-Этот монитор смотрит на `max(period_month)` вторичного сегмента по региону и
-поднимает per-day ERROR-алерт, когда данные устарели СВЕРХ допустимого лага
-публикации — так ops видит дрейф на MONITOR-частоте, а не по крупицам в логах.
+Монитор мерил `now() - max(period_month)` и алертил при возрасте > 60 суток
+(sber_index_max_age_days 35 + lag_allowance 25). Такой возраст НЕДОСТИЖИМО МАЛ по
+построению: `period_month` — метка ПЕРВОГО числа месяца, поэтому на закрытии месяца
+возрасту уже ≥30; плюс собственный лаг публикации источника. За 31 сутки прямых
+наблюдений монитора (07-13 … 08-12, scrape_runs.counters) возраст лежал в 46..76 и
+НИ РАЗУ не опускался ниже 46. Порог 35 у оценщика был истинным 100% времени — ноль бит.
-#2674 — почему ERROR, а не WARNING. В контейнере скрапера GlitchTip поднят с
-LoggingIntegration(event_level=ERROR) (scheduler_main.py), поэтому WARNING
-событием НЕ становится вообще. Бенчмарк цен участвует в сверке наших медиан, его
-застой — сбой, а не наблюдение. Сосед по конструкции (deals_freshness_monitor)
-писал ERROR с самого начала — расходилась только эта джоба.
+Порог 60 у монитора не лучше: он лежит ВНУТРИ рабочего диапазона, поэтому монитор
+мерил не источник, а нашу же пилу. Миграция 212 (такт 28 → 7) обещала потолок
+возраста ≈46+7=53 < 60. Прод это ОПРОВЕРГ: 2026-08-12 возраст 72 при ПОЛНОМ прогоне
+загрузки шестидневной давности (08-06, errors=0, upserted=639) — источник просто не
+опубликовал июль. Двенадцать суток подряд (08-01 … 08-12) монитор писал ERROR при
+исправной загрузке. Потолок 53 держится, только если источник публикует строго
+помесячно; он не публикует.
-ВАЖНО про «9 срабатываний» из #2674 (ревью PR #2681, прод-разбор всех 24 прогонов
-монитора 2026-08-06). Эти девять НЕ были застоем бенчмарка — это была ПИЛА нашего
-собственного такта загрузки:
- 13-16.07 alert=1 age 73..76 latest=май 01-05.08 alert=1 age 61..65
- 17.07 alert=0 age 46 latest=июнь (день загрузки)
-Загрузка ходила раз в 28 дней и приносила период на месяц новее, возраст же
-считается от ПЕРВОГО числа покрытого месяца → пол ~46 в момент загрузки, потолок
-46+28=74, порог 60 ВНУТРИ диапазона, тревога 14 суток из 28 каждый цикл. Поднимать
-такое до ERROR без починки такта значило бы завести ежедневное ложное событие на
-две недели в месяц. Поэтому миграция 212 перевела sber_index_pull на НЕДЕЛЬНЫЙ
-такт: потолок возраста ≈ пол+7 ≈ 53 при пороге 60, тревога снова означает
-«источник/загрузка встали», а не «мы давно не ходили».
+ЧТО МЕРИМ ТЕПЕРЬ. Загрузчик тянет ВСЮ серию (limit=1000&offset=0, отсечки по периоду
+нет), поэтому после прогона с errors=0 AND upserted>0 наш max(period_month) РАВЕН
+максимуму источника ПО ПОСТРОЕНИЮ. Значит вопрос «отстали ли мы» — это вопрос
+«давно ли был последний ЗАВЕДОМО ПОЛНЫЙ прогон», и он не зависит от возраста периода:
-Порог алерта (документирование выбора):
- Per-estimate guard (estimator): age > settings.sber_index_max_age_days (35д).
- Монитор: age > sber_index_max_age_days + lag_allowance.
- lag_allowance (DEFAULT_LAG_ALLOWANCE_DAYS=25) — запас на ИНХЕРЕНТНЫЙ лаг
- публикации СберИндекса: источник отстаёт на 1-2 месяца, а period_month — лейбл
- ПЕРВОГО числа месяца, поэтому даже свежайшая загрузка даёт возраст ~46 суток.
- Итог: 35 + 25 = 60д. При недельном такте (миграция 212) рабочий диапазон возраста
- ~46..53 — до порога остаётся ~7 суток запаса: один пропущенный недельный цикл
- поглощается, два подряд дают тревогу. Порог НЕ должен снова оказаться внутри
- рабочего диапазона — если такт загрузки будут менять, пересчитай потолок
- (пол + interval_days) и сверь с 60.
+ последний полный прогон свежий → наш max == max источника → источник не публиковал,
+ молчание ПРАВИЛЬНОЕ (возраст = лаг источника);
+ последний полный прогон старый → мы не забрали → тревога про ЗАГРУЗЧИК.
-Задача синхронная (DB-only, один SELECT max(period_month)) — запускается
-kit-scheduler'ом через product_handlers._job_sber_freshness_monitor в
-run_in_executor, по образцу deals_freshness_monitor. Вердикт вычисляет ЧИСТАЯ
-функция evaluate_sber_freshness() (frozen-now тестируется без БД).
+ЛОВУШКА: `status='done'` НЕ означает успех — прогон id=37 (2026-05-31) имеет
+{errors: 9, upserted: 0} и статус done. Успех = errors=0 AND upserted>0 (все 9 серий
+3 табло × 3 региона прошли: errors — счётчик по всему прогону).
-Прогон НЕ помечается failed при алерте (это МОНИТОР, а не сбой джобы) — ERROR-записи
-достаточно. mark_failed только если sber_price_index недоступна/пуста (нечего
-оценивать).
+ПОРОГ — не круглое число, а такт самой загрузки: `scrape_schedules.default_params
+.interval_days` для sber_index_pull, ЧИТАЕТСЯ ИЗ ТОЙ ЖЕ СТРОКИ, по которой планировщик
+запускает прогон (orchestration/scheduler.py::_defer_next_run_at). Разъехаться с
+тактом порог не может: поменяли такт — порог поехал следом. Тревога после
+MISSED_PULL_CYCLES=2 пропущенных тактов: один пропуск (сдвиг окна, разовый сбой сети)
+поглощается, два подряд означают, что загрузка встала. При нынешнем такте 7 это 14
+суток; на прод-истории такое состояние ДОСТИЖИМО — разрывы между полными прогонами
+были 14.8 и 20 суток (05-31→06-15 и 07-17→08-06).
+
+ПО ТАБЛО, А НЕ ПО max() ВСЕЙ ТАБЛИЦЫ. Оценщик берёт ПЕРВОЕ НЕПУСТОЕ табло из
+estimator.SBER_COEFF_DASHBOARDS; у real_estate_deals latest=2026-06, у
+dinamika-tsen-obyavlenii — 2026-05 (на 2026-08-12). max() по таблице маскирует
+отставшее табло, поэтому монитор идёт тем же порядком, что и оценщик, и берёт ту же
+серию — список импортируется из estimator, дублировать его тут нельзя.
+
+ЧЕГО ЭТОТ МОНИТОР НЕ ЛОВИТ (осознанно, #2846). Если источник ЗАМОЛЧИТ НАВСЕГДА, а
+загрузка останется исправной — монитор промолчит: по нашим данным «источник не
+публиковал 2 месяца» неотличимо от «источник публикует раз в 2 месяца». Такт
+публикации источника ретроспективно невосстановим — его затёр апсерт
+(sber_index.py ставил fetched_at=now() всем строкам серии). С этого PR fetched_at
+не переписывается при конфликте и означает «когда мы ВПЕРВЫЕ увидели этот период»,
+т.е. такт публикации станет измеримым; вернуться к вопросу порога «источник встал»
+имеет смысл после 3 наблюдённых публикаций (ориентир — ноябрь 2026).
+
+ERROR, а не WARNING (#2674): в контейнере скрапера GlitchTip поднят с
+LoggingIntegration(event_level=ERROR), WARNING событием не становится вообще.
+
+Задача синхронная (DB-only) — запускается kit-scheduler'ом через
+product_handlers._job_sber_freshness_monitor в run_in_executor. Вердикт считает
+ЧИСТАЯ функция evaluate_sber_freshness() (frozen-now, тестируется без БД).
+
+Прогон НЕ помечается failed при алерте (это МОНИТОР, а не сбой джобы). mark_failed
+только если у оценщика вообще нет серии (нечего оценивать).
"""
from __future__ import annotations
@@ -62,153 +74,239 @@ from datetime import UTC, date, datetime
from sqlalchemy import text
from sqlalchemy.orm import Session
-from app.core.config import settings
from app.services import scrape_runs as runs_mod
+from app.services.estimator import SBER_COEFF_DASHBOARDS, SBER_TIME_ADJUST_REGION
logger = logging.getLogger(__name__)
__all__ = [
- "DEFAULT_LAG_ALLOWANCE_DAYS",
+ "DEFAULT_PULL_INTERVAL_DAYS",
+ "MISSED_PULL_CYCLES",
+ "SBER_FRESHNESS_PULL_SOURCE",
"SberFreshnessVerdict",
"check_sber_freshness",
"evaluate_sber_freshness",
]
-# Запас на инхерентный лаг публикации СберИндекса (дней) СВЕРХ per-estimate
-# guard'а settings.sber_index_max_age_days. Читается из default_params.lag_allowance_days.
-DEFAULT_LAG_ALLOWANCE_DAYS = 25
+# Джоба-загрузчик, чей такт и успешность мы и мониторим.
+SBER_FRESHNESS_PULL_SOURCE = "sber_index_pull"
-# Регион продукта (Trade-in — Свердловская область). Совпадает с city-значениями
-# sber_price_index для областного уровня.
-SBER_MONITOR_CITY = "Свердловская область"
+# Сколько тактов загрузки подряд можно пропустить до тревоги. 1 = разовый сбой/сдвиг
+# окна (поглощаем), 2 = загрузка встала (алерт).
+MISSED_PULL_CYCLES = 2
-_LATEST_SBER_PERIOD_SQL = text("""
+# Фолбэк, если в scrape_schedules нет строки/ключа interval_days (миграция 212 ставит 7).
+DEFAULT_PULL_INTERVAL_DAYS = 7
+
+_LATEST_PERIOD_SQL = text("""
SELECT max(period_month) AS latest
FROM sber_price_index
WHERE city = CAST(:city AS text)
+ AND dashboard = CAST(:dash AS text)
-- #R2-H1: только вторичный рынок (эстиматор — вторичка); первичка
-- (новостройки) = направленно неверная коррекция. Зеркалит фильтр
-- estimator._load_sber_index_series.
AND (segment IS NULL OR segment ILIKE '%вторичн%')
""")
+# Последний ЗАВЕДОМО ПОЛНЫЙ прогон загрузчика. status='done' сюда не входит намеренно:
+# прогон id=37 имеет done при {errors: 9, upserted: 0}. Сравнения — jsonb-ные, без
+# CAST(... AS int): counters других источников планировщик может отфильтровать позже
+# каста, а не раньше, и нечисловое значение уронило бы запрос. Для jsonb-чисел
+# оператор > численный.
+_LAST_COMPLETE_PULL_SQL = text("""
+ SELECT max(finished_at) AS last_pull
+ FROM scrape_runs
+ WHERE source = CAST(:src AS text)
+ AND counters @> CAST('{"errors": 0}' AS jsonb)
+ AND counters -> 'upserted' > CAST('0' AS jsonb)
+""")
+
+# Такт загрузки — из той же строки, по которой планировщик считает next_run_at.
+_PULL_INTERVAL_SQL = text("""
+ SELECT default_params ->> 'interval_days' AS interval_days
+ FROM scrape_schedules
+ WHERE source = CAST(:src AS text)
+""")
+
@dataclass(frozen=True)
class SberFreshnessVerdict:
- """Вердикт свежести СберИндекса по max(period_month)."""
+ """Вердикт: отстала ли ЗАГРУЗКА СберИндекса от собственного такта."""
latest_period: date
- age_days: int
+ age_days: int # наблюдение (лаг публикации источника), НЕ критерий тревоги
+ pull_lag_days: int # суток с последнего полного прогона; -1 = полных прогонов не было
+ max_pull_lag_days: int # порог = MISSED_PULL_CYCLES × такт загрузки
stale: bool
def evaluate_sber_freshness(
latest_period: date,
now: datetime,
- max_age_days: int,
+ *,
+ last_complete_pull_at: datetime | None,
+ pull_interval_days: int,
) -> SberFreshnessVerdict:
- """Чистая логика: устарел ли latest период СберИндекса.
+ """Чистая логика: отстала ли загрузка от собственного такта.
- stale = age_days > max_age_days, где age_days = now.date() - latest_period.
- `max_age_days` — ПОЛНЫЙ порог монитора (per-estimate guard + lag_allowance),
- вычисляется вызывающим check_sber_freshness. Тестируется с frozen `now` без БД.
+ stale = полных прогонов не было ВООБЩЕ, либо последний старше
+ MISSED_PULL_CYCLES × pull_interval_days. Возраст периода считается и кладётся в
+ вердикт как НАБЛЮДЕНИЕ, но на вердикт не влияет: после полного прогона наш
+ max(period_month) равен максимуму источника по построению, и его возраст — это
+ лаг ПУБЛИКАЦИИ, на который мы повлиять не можем.
"""
age_days = (now.date() - latest_period).days
- stale = age_days > max_age_days
+ max_pull_lag_days = MISSED_PULL_CYCLES * pull_interval_days
+ if last_complete_pull_at is None:
+ return SberFreshnessVerdict(latest_period, age_days, -1, max_pull_lag_days, True)
+ pull_lag_days = (now - last_complete_pull_at).days
return SberFreshnessVerdict(
latest_period=latest_period,
age_days=age_days,
- stale=stale,
+ pull_lag_days=pull_lag_days,
+ max_pull_lag_days=max_pull_lag_days,
+ stale=pull_lag_days > max_pull_lag_days,
)
+def _load_estimator_dashboard(db: Session) -> tuple[str, date] | None:
+ """Табло, которое возьмёт оценщик, и его latest период.
+
+ Тот же порядок, что и estimator._load_sber_index_series: первое НЕПУСТОЕ табло
+ из SBER_COEFF_DASHBOARDS. max() по всей таблице маскировал бы отставшее табло.
+ """
+ for dash in SBER_COEFF_DASHBOARDS:
+ row = db.execute(
+ _LATEST_PERIOD_SQL, {"city": SBER_TIME_ADJUST_REGION, "dash": dash}
+ ).first()
+ latest = row.latest if row is not None else None
+ if latest is not None:
+ return dash, latest
+ return None
+
+
+def _pull_interval_days(db: Session) -> int:
+ """Такт загрузчика из scrape_schedules (фолбэк DEFAULT_PULL_INTERVAL_DAYS)."""
+ row = db.execute(_PULL_INTERVAL_SQL, {"src": SBER_FRESHNESS_PULL_SOURCE}).first()
+ raw = row.interval_days if row is not None else None
+ try:
+ return int(raw) if raw is not None else DEFAULT_PULL_INTERVAL_DAYS
+ except (TypeError, ValueError):
+ logger.warning(
+ "sber freshness: interval_days=%r в scrape_schedules нечисловой — беру %d",
+ raw,
+ DEFAULT_PULL_INTERVAL_DAYS,
+ )
+ return DEFAULT_PULL_INTERVAL_DAYS
+
+
def check_sber_freshness(
db: Session,
run_id: int,
params: dict | None = None, # type: ignore[type-arg]
now: datetime | None = None,
) -> dict[str, int]:
- """Проверить свежесть СберИндекса по max(period_month) и алертить при staleness.
+ """Проверить, не отстала ли загрузка СберИндекса, и алертить при отставании.
Sync (вызывается scheduler-триггером в executor, как check_deals_freshness).
- Читает один SELECT max(period_month) вторичного сегмента по региону, считает
- вердикт чистой функцией, логирует WARNING при stale (per-day surfacing для ops)
- и финализирует run.
+ Читает: latest период табло оценщика, время последнего ПОЛНОГО прогона
+ sber_index_pull, такт загрузки из scrape_schedules. Вердикт — чистой функцией.
- Params (default_params jsonb):
- lag_allowance_days: int — запас сверх sber_index_max_age_days (default 25).
- `now` инъектируется в тестах (frozen); в проде — None → datetime.now(UTC).
+ `params` больше ничего не настраивает: порог берётся из такта самой загрузки
+ (унаследованный default_params.lag_allowance_days=25 монитора игнорируется —
+ он кодировал мёртвый календарный порог). `now` инъектируется в тестах.
- Returns counters {latest_year, latest_month, age_days, alert}.
- mark_failed только если sber_price_index пуста/недоступна (нечего оценивать);
+ Returns counters {latest_year, latest_month, age_days, pull_lag_days,
+ max_pull_lag_days, alert}.
+ mark_failed только если у оценщика нет серии вообще (нечего оценивать);
при алерте прогон помечается done (это монитор, не сбой джобы).
"""
- params = params or {}
now = now or datetime.now(UTC)
counters: dict[str, int] = {
"latest_year": 0,
"latest_month": 0,
"age_days": 0,
+ "pull_lag_days": -1,
+ "max_pull_lag_days": 0,
"alert": 0,
}
try:
runs_mod.update_heartbeat(db, run_id, counters)
- row = db.execute(_LATEST_SBER_PERIOD_SQL, {"city": SBER_MONITOR_CITY}).first()
- latest: date | None = row.latest if row is not None else None
- if latest is None:
+ found = _load_estimator_dashboard(db)
+ if found is None:
# ERROR (#2674): монитор не может выполнить свою работу вовсе — это сбой,
# а не наблюдение. mark_failed ниже виден только стрик-алерту (3 подряд),
# а монитор ходит раз в сутки — три дня молчания на пустом бенчмарке.
logger.error(
- "sber freshness: sber_price_index пуст/недоступен для region=%s "
- "(вторичка) — оценить свежесть нельзя",
- SBER_MONITOR_CITY,
+ "sber freshness: у оценщика нет серии — ни одно табло %s не даёт строк "
+ "для region=%s (вторичка); оценить нечего",
+ list(SBER_COEFF_DASHBOARDS),
+ SBER_TIME_ADJUST_REGION,
)
runs_mod.mark_failed(db, run_id, "sber_price_index empty or unavailable", counters)
return counters
- lag_days = int(params.get("lag_allowance_days", DEFAULT_LAG_ALLOWANCE_DAYS))
- max_age_days = settings.sber_index_max_age_days + lag_days
- verdict = evaluate_sber_freshness(latest, now, max_age_days)
+ dashboard, latest = found
+ last_pull_row = db.execute(
+ _LAST_COMPLETE_PULL_SQL, {"src": SBER_FRESHNESS_PULL_SOURCE}
+ ).first()
+ last_complete_pull_at = last_pull_row.last_pull if last_pull_row is not None else None
+ verdict = evaluate_sber_freshness(
+ latest,
+ now,
+ last_complete_pull_at=last_complete_pull_at,
+ pull_interval_days=_pull_interval_days(db),
+ )
counters = {
"latest_year": latest.year,
"latest_month": latest.month,
"age_days": verdict.age_days,
+ "pull_lag_days": verdict.pull_lag_days,
+ "max_pull_lag_days": verdict.max_pull_lag_days,
"alert": int(verdict.stale),
}
if verdict.stale:
- # ERROR (#2674): WARNING не долетает до GlitchTip (event_level=ERROR) —
- # 9 срабатываний на проде дали ноль событий. См. докстринг модуля.
+ # ERROR (#2674): WARNING не долетает до GlitchTip (event_level=ERROR).
logger.error(
- "sber freshness: max(period_month)=%s устарел на %d дней "
- "(> порога %d = sber_index_max_age_days %d + lag %d); "
- "СберИндекс time-adjustment ДКП-сделок мог отстать — "
- "проверь sber_index_pull и доступность новых периодов источника",
+ "sber freshness: загрузка СберИндекса отстала — последний ПОЛНЫЙ прогон "
+ "%s (%s суток назад, порог %d = %d такта × %d суток; "
+ "status='done' с errors>0 за успех НЕ считается). "
+ "Наш max(period_month)=%s (табло %s) мог разойтись с источником — "
+ "проверь sber_index_pull: планировщик, сеть, /api/sowa 404",
+ last_complete_pull_at.isoformat() if last_complete_pull_at else "НИ РАЗУ",
+ verdict.pull_lag_days if verdict.pull_lag_days >= 0 else "∞",
+ verdict.max_pull_lag_days,
+ MISSED_PULL_CYCLES,
+ verdict.max_pull_lag_days // MISSED_PULL_CYCLES,
latest,
- verdict.age_days,
- max_age_days,
- settings.sber_index_max_age_days,
- lag_days,
+ dashboard,
)
else:
logger.info(
- "sber freshness: max(period_month)=%s свежий (age=%d дней ≤ порога %d) "
- "region=%s — алерта нет",
+ "sber freshness: загрузка в такте — последний полный прогон %d суток назад "
+ "(≤ порога %d). max(period_month)=%s (табло %s, возраст %d суток) равен "
+ "максимуму источника по построению: возраст = лаг ПУБЛИКАЦИИ источника, "
+ "не наше отставание — алерта нет",
+ verdict.pull_lag_days,
+ verdict.max_pull_lag_days,
latest,
+ dashboard,
verdict.age_days,
- max_age_days,
- SBER_MONITOR_CITY,
)
runs_mod.mark_done(db, run_id, counters)
logger.info(
- "check_sber_freshness run_id=%d done: latest=%s alert=%d age_days=%d",
+ "check_sber_freshness run_id=%d done: latest=%s dash=%s alert=%d "
+ "pull_lag_days=%d age_days=%d",
run_id,
latest,
+ dashboard,
counters["alert"],
+ counters["pull_lag_days"],
counters["age_days"],
)
return counters
diff --git a/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py b/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py
index 51e7412c..48771f0b 100644
--- a/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py
+++ b/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py
@@ -22,8 +22,14 @@ max_consecutive_blocks. Прогон с нулём обогащений тепе
ведёт на сайт застройщика, а не на realty.yandex.ru/offer//. Парсер отвергает
такие URL регуляркой ДО сети — это не капча, а предрешённый parse→None. Идут они
пачками, поэтому «5 подряд» набиралось на первых же строках и обрывало прогон
-целиком. Теперь снапшот-SELECT берёт только то, что парсер в принципе может
-разобрать, а размер отброшенного видно в counters.unenrichable_pending.
+целиком. Снапшот-SELECT берёт только то, что парсер в принципе может разобрать.
+
+Но «не по тому URL» ≠ «нечего обогащать» (разобрано 2026-08-12, см. комментарий
+у OFFER_ID_PATTERN): у ВСЕХ таких строк в source_id лежит yandex offerId, и по
+собранному из него каноническому URL страница отдаётся и парсится. Поэтому в
+очередь они входят по адресу, ВЫЧИСЛЕННОМУ из source_id, а counters разделены:
+url_from_offer_id — сколько ждёт починки адреса, unenrichable_pending — сколько
+не адресуемо вообще (ни offer-URL, ни числового source_id).
Why curl_cffi and not YandexDetailScraper.fetch_detail:
fetch_detail uses BaseScraper._http_get (plain httpx, no proxy, no TLS
@@ -51,6 +57,8 @@ from app.services.proxy_egress import resolve_proxy_url
logger = logging.getLogger(__name__)
__all__ = [
+ "CANONICAL_URL_SQL",
+ "OFFER_ID_PATTERN",
"OFFER_URL_PATTERN",
"YandexDetailBackfillResult",
"run_yandex_detail_backfill",
@@ -63,8 +71,7 @@ __all__ = [
#
# Замер прода 2026-08-06: из 15 511 необогащённых yandex-объявлений 3 535 имеют
# source_url на сайт застройщика (macroserver.ru, prospect-federation.ru,
-# strana.com, …) — так карточки новостроек ведут с выдачи Яндекса. Обогащено из
-# них за всю историю 0; все 1 210 обогащённых — вида realty.yandex.ru/offer//.
+# strana.com, …) — так карточки новостроек ведут с выдачи Яндекса.
#
# Вред не в бесполезности, а в том, что они идут ПАЧКАМИ (один свип — один
# застройщик) и упираются в брейкер «5 parse-None подряд», обрывающий ВЕСЬ прогон:
@@ -72,6 +79,39 @@ __all__ = [
# Плюс каждая такая попытка — запрос на чужой сайт, который мы всё равно выбросим.
OFFER_URL_PATTERN = "/offer/[0-9]+"
+# ── «Непригодных» не бывает без причины (разобрано 2026-08-12) ────────────────
+# Симптом: unenrichable_pending шесть прогонов подряд равнялся РОВНО 3535 — ни на
+# единицу, при том что очередь обогащалась по ~500/прогон. Замер на проде:
+#
+# * счётчик считается живым SELECT'ом, кэша/матвьюхи нет — арифметика честная;
+# * множество замкнуто: новых строк в него не приходит (0 из 6892 yandex-строк,
+# вставленных после самой свежей его строки, id 2583989), и выйти из него
+# нельзя (обогащение недостижимо, source_url не переписывается). Замкнутое
+# множество и обязано быть константой — вопрос был не «почему не растёт», а
+# «правда ли они непригодны».
+#
+# Непригодны они НЕ были. У всех 3535 в source_id лежит числовой yandex offerId
+# (у 3523 он же продублирован в yandex_offer_id), а канонический адрес оффера из
+# него собирается — это инвариант #2235 (`_canonical_source_url` в
+# scraper_kit/providers/yandex/serp.py) и та же формула, которой миграция 164
+# чинила легаси-строки. Живая проба 2026-08-12 прод-трактом (тот же прокси,
+# curl_cffi chrome120, тот же parse): 6 из 6 — HTTP 200 и parse OK, включая
+# строки, чей сохранённый source_url — рекламный редирект na100.pro/go.php.
+#
+# Откуда взялся стухший адрес: source_url пишется ТОЛЬКО при вставке — его нет ни
+# в `ON CONFLICT DO UPDATE`, ни в reconcile-UPDATE у `save_listings`. Значит #2235
+# вылечил только новые строки, миграция 164 — только те легаси, чей URL ДЕЛИЛИ
+# несколько строк (она искала дубли URL, а не непарсимость). Строки с уникальной
+# ссылкой на карточку застройщика не попали ни туда, ни туда и носят адрес,
+# замороженный в момент вставки, хотя свип переобходит ~511 из них в сутки.
+#
+# Поэтому адресуем такие строки вычисленным URL, а не сохранённым. Починка самой
+# колонки (одноразовый UPDATE, тот же 164 без условия на дубли) — за миграцией:
+# от неё зависит и yandex_address_backfill, где 1618 из 5217 кандидатов ходят
+# на сайты застройщиков вместо Яндекса.
+OFFER_ID_PATTERN = "^[0-9]+$"
+CANONICAL_URL_SQL = "'https://realty.yandex.ru/offer/' || source_id || '/'"
+
@dataclass
class YandexDetailBackfillResult:
@@ -80,6 +120,11 @@ class YandexDetailBackfillResult:
attempted: int = 0
enriched: int = 0
failed: int = 0
+ # Ждут обогащения, сохранённый source_url непарсим, но адрес восстановим из
+ # source_id — идут в очередь по вычисленному URL. Должен убывать от прогона к
+ # прогону; замер на месте = очередь снова читается не тем признаком.
+ url_from_offer_id: int = 0
+ # Ждут обогащения и адресовать их НЕЧЕМ: ни offer-URL, ни числового source_id.
unenrichable_pending: int = 0
duration_sec: float = field(default=0.0)
@@ -88,6 +133,7 @@ class YandexDetailBackfillResult:
"attempted": self.attempted,
"enriched": self.enriched,
"failed": self.failed,
+ "url_from_offer_id": self.url_from_offer_id,
"unenrichable_pending": self.unenrichable_pending,
"duration_sec": int(self.duration_sec),
}
@@ -132,51 +178,86 @@ async def run_yandex_detail_backfill(
# SNAPSHOT: single SELECT at start -- NOT re-selected in loop.
# Priority: is_active DESC (active first), scraped_at DESC (newest first).
- # Гейт по OFFER_URL_PATTERN — тот же признак, по которому парсер отказывает
- # (см. комментарий у константы): в очередь не берём то, что заведомо
- # непарсимо, иначе пачка карточек застройщика обрывает прогон брейкером.
+ # В очередь идёт то, для чего есть АДРЕС, который парсер примет: либо
+ # сохранённый source_url подходит под OFFER_URL_PATTERN, либо адрес
+ # собирается из source_id (см. комментарий у OFFER_ID_PATTERN). Что шире
+ # этого условия — гарантированный parse→None пачкой и обрыв по брейкеру.
snapshot = (
db.execute(
text(
- """
- SELECT id, source_url
+ f"""
+ SELECT id,
+ CASE
+ WHEN source_url ~ CAST(:offer_url_pattern AS text)
+ THEN source_url
+ ELSE {CANONICAL_URL_SQL}
+ END AS source_url
FROM listings
WHERE source = 'yandex'
AND detail_enriched_at IS NULL
- AND source_url IS NOT NULL
- AND source_url ~ CAST(:offer_url_pattern AS text)
+ AND (
+ (
+ source_url IS NOT NULL
+ AND source_url ~ CAST(:offer_url_pattern AS text)
+ )
+ OR source_id ~ CAST(:offer_id_pattern AS text)
+ )
ORDER BY is_active DESC NULLS LAST, scraped_at DESC NULLS LAST
LIMIT CAST(:batch_size AS int)
"""
+ # f-string здесь безопасен: CANONICAL_URL_SQL — литерал модуля,
+ # не пользовательский ввод. Всё изменяемое — bind-параметры.
),
- {"batch_size": batch_size, "offer_url_pattern": OFFER_URL_PATTERN},
+ {
+ "batch_size": batch_size,
+ "offer_url_pattern": OFFER_URL_PATTERN,
+ "offer_id_pattern": OFFER_ID_PATTERN,
+ },
)
.mappings()
.all()
)
# Отброшенное не должно исчезнуть из виду: без этого счётчика «обогащено
- # 12 тыс. из 15,5 тыс.» снова стало бы необъяснимым нулём (#2674).
- counters.unenrichable_pending = int(
- db.execute(
- text(
- """
- SELECT count(*)
- FROM listings
- WHERE source = 'yandex'
- AND detail_enriched_at IS NULL
- AND source_url IS NOT NULL
- AND source_url !~ CAST(:offer_url_pattern AS text)
- """
- ),
- {"offer_url_pattern": OFFER_URL_PATTERN},
- ).scalar_one()
- )
- if counters.unenrichable_pending:
+ # 12 тыс. из 15,5 тыс.» снова стало бы необъяснимым нулём (#2674). И оно
+ # разделено по ПРИЧИНЕ: одно число на две разные судьбы читалось как
+ # «тут делать нечего» и держало 3535 квартир вне обогащения неделю.
+ pending = db.execute(
+ text(
+ """
+ SELECT
+ count(*) FILTER (
+ WHERE source_id ~ CAST(:offer_id_pattern AS text)
+ ) AS url_from_offer_id,
+ count(*) FILTER (
+ WHERE source_id IS NULL
+ OR source_id !~ CAST(:offer_id_pattern AS text)
+ ) AS unenrichable_pending
+ FROM listings
+ WHERE source = 'yandex'
+ AND detail_enriched_at IS NULL
+ AND (
+ source_url IS NULL
+ OR source_url !~ CAST(:offer_url_pattern AS text)
+ )
+ """
+ ),
+ {"offer_url_pattern": OFFER_URL_PATTERN, "offer_id_pattern": OFFER_ID_PATTERN},
+ ).one()
+ counters.url_from_offer_id = int(pending.url_from_offer_id)
+ counters.unenrichable_pending = int(pending.unenrichable_pending)
+ if counters.url_from_offer_id:
logger.info(
- "yandex_detail_backfill: run_id=%d — %d объявлений вне очереди: "
- "source_url ведёт не на карточку Яндекса (%s), парсер их отвергает "
- "до сети",
+ "yandex_detail_backfill: run_id=%d — у %d объявлений сохранённый "
+ "source_url не ведёт на карточку Яндекса; адресуем их по offerId из "
+ "source_id (колонку чинит миграция, см. OFFER_ID_PATTERN)",
+ run_id,
+ counters.url_from_offer_id,
+ )
+ if counters.unenrichable_pending:
+ logger.warning(
+ "yandex_detail_backfill: run_id=%d — %d объявлений вне очереди: нет ни "
+ "offer-URL (%s), ни числового source_id — адресовать их нечем",
run_id,
counters.unenrichable_pending,
OFFER_URL_PATTERN,
diff --git a/tradein-mvp/backend/data/sql/257_listings_backfill_yandex_source_url.sql b/tradein-mvp/backend/data/sql/257_listings_backfill_yandex_source_url.sql
new file mode 100644
index 00000000..05f29a7c
--- /dev/null
+++ b/tradein-mvp/backend/data/sql/257_listings_backfill_yandex_source_url.sql
@@ -0,0 +1,150 @@
+-- 257_listings_backfill_yandex_source_url.sql
+-- Разовое лечение source_url у yandex-строк, чей адрес ведёт на сайт застройщика (#2838).
+--
+-- WHY. `source_url` пишется ТОЛЬКО при вставке: его нет ни в `ON CONFLICT DO UPDATE`,
+-- ни в reconcile-UPDATE (`scraper_kit/base.py`). Поэтому починка продюсера (#2235,
+-- `_canonical_source_url` в providers/yandex/serp.py) вылечила только НОВЫЕ строки,
+-- а миграция 164 — только те легаси, чей URL ДЕЛИЛИ несколько строк (её CTE `shared`
+-- искал дубли URL, а не непарсимость адреса). Строки с УНИКАЛЬНОЙ ссылкой на карточку
+-- застройщика не попали ни туда, ни туда и носят адрес, замороженный в момент вставки.
+-- Цена простоя: `YandexDetailScraper.parse` первым делом ищет в URL `/offer/<цифры>/`
+-- и без него возвращает None ещё ДО обращения к HTML — такие строки не обогащаются
+-- никогда, а `yandex_address_backfill` вдобавок ходит по ним на чужие сайты.
+-- PR #2838 научил ОЧЕРЕДЬ адресовать их по source_id; колонку чинит эта миграция.
+--
+-- ЗАМЕР НА ПРОДЕ 2026-08-12 (SELECT-only, не «по описанию из issue»):
+--
+-- source='yandex' AND source_url !~ '/offer/[0-9]+' строк
+-- ------------------------------------------------------- -----
+-- всего 3535
+-- из них source_id ~ '^[0-9]+$' (адрес восстановим) 3535
+-- из них source_id NULL/нечисловой (нечем адресовать) 0
+-- из них source_url IS NULL 0
+-- из них is_active 3522
+--
+-- хосты: macroserver.ru 912, macro.sbercrm.com 440, akademicheskiy.org 356,
+-- na100.pro 331, strana.com 318, ten-stroy.ru 189, ecologica.ru 167,
+-- xn--b1agbiqxpe4gxa.xn--p1ai 145, sinara-development.ru 114,
+-- ekaterinburg.razum.life 111, www.lsr.ru 81, samolet.ru 68, хвост.
+--
+-- Множество ЗАМКНУТО (важно: значит список ниже не устареет между PR и деплоем):
+-- самая свежая его строка — id 2583989, после неё вставлено 6892 yandex-строк,
+-- и НИ ОДНА в множество не попала — продюсер после #2235 таких адресов не пишет.
+-- Множество может только уменьшаться (удаление строк), не расти.
+--
+-- ФОРМА АДРЕСА — ДОСЛОВНО ПРОДЮСЕРНАЯ, не изобретённая здесь.
+-- scraper_kit/providers/yandex/serp.py::_canonical_source_url:
+-- return f"https://realty.yandex.ru/offer/{offer_id}/" # ветка «url не ведёт на realty.yandex»
+-- тот же литерал живёт в app/tasks/yandex_detail_backfill.py::CANONICAL_URL_SQL
+-- "'https://realty.yandex.ru/offer/' || source_id || '/'"
+-- и та же формула стоит в миграции 164. Ниже — она же, посимвольно;
+-- tests/test_migration_257_yandex_source_url_backfill.py держит это сцепление
+-- (сравнивает выражение из ЭТОГО файла с CANONICAL_URL_SQL, который, в свою
+-- очередь, уже сверен с продюсером в test_yandex_detail_backfill.py).
+-- Условия отбора — те же строковые константы OFFER_URL_PATTERN ('/offer/[0-9]+')
+-- и OFFER_ID_PATTERN ('^[0-9]+$'), которыми очередь #2838 отбирает эти же строки.
+--
+-- КОЛЛИЗИЙ НЕТ — ПРОВЕРЕНО, А НЕ ЗАЯВЛЕНО:
+-- * новый URL, уже занятый ДРУГОЙ строкой listings (любой источник): 0;
+-- * два кандидата с одинаковым новым URL внутри самого множества: 0
+-- (source_id уникален по constraint 133_listings_uq_source_source_id.sql);
+-- * после UPDATE в dry-run дублей source_url среди ВСЕХ yandex-строк: 0.
+--
+-- DRY-RUN НА ПРОДЕ (BEGIN … ROLLBACK, 2026-08-12, тем же телом, что ниже):
+-- UPDATE 3535 · осталось непарсимых 0 · дублей source_url у yandex 0
+-- счётчики очереди #2838 после: url_from_offer_id 3535 → 0, unenrichable_pending 0
+-- yandex_address_backfill (кандидаты 5545): с непарсимым URL 1777 → 0
+--
+-- было → стало (10 строк, взяты по id DESC):
+-- 2583989 https://ekaterinburg.razum.life/flats/7228451 → .../offer/7087563582288224501/
+-- 2583986 https://sinara-development.ru/#/macrocatalog/… → .../offer/6990986462977811151/
+-- 2583940 https://www.an-nks.ru/catalog/38/4241/ → .../offer/7567121745684380093/
+-- 2583938 https://ten-stroy.ru/parametric/osnovinskiye-… → .../offer/5227777077487552091/
+-- 2583931 https://ekaterinburg.razum.life/flats/7225097 → .../offer/7087563582288131414/
+-- 2583929 https://samolet.ru/ekaterinburg/project/payer/… → .../offer/1827858605736006765/
+-- 2583923 https://samolet.ru/ekaterinburg/project/auruum/… → .../offer/2812449412758148821/
+-- 2583917 http://na100.pro/go.php?link=uRy09YqcU9pqegrRc… → .../offer/895871493295352384/
+-- 2583891 https://macroserver.ru/id/8783797/ → .../offer/6378643964567459685/
+-- 2583878 https://strana.com/ekb/uralskij-sad/flats/14986370→ .../offer/6591508026346911121/
+-- (префикс «стало» везде один: https://realty.yandex.ru/offer//)
+-- Живая проба прод-трактом 2026-08-12 (тот же прокси, curl_cffi chrome120, тот же
+-- parse) по таким восстановленным адресам: 6 из 6 — HTTP 200 и parse OK.
+--
+-- ОБРАТИМОСТЬ — ТАБЛИЦА, А НЕ ПРЕДИКАТ, И ВОТ ПОЧЕМУ (проверено, а не предположено).
+-- Ход «прежнее значение уже где-то лежит» (как в 254, где им был
+-- raw_payload->>'address') здесь НЕ работает:
+-- * listings.raw_payload ключа 'url' НЕ содержит: 0 из 3535. Ключи там
+-- ceiling_height, kitchen_area_m2, offer_id, page_param, raw_building_type,
+-- site_name — адреса нет ни под одним именем;
+-- * listings.house_url / newbuilding_url у всех 3535 = NULL;
+-- * listing_sources.source_url (тоже insert-only: в его ON CONFLICT DO UPDATE
+-- source_url отсутствует) хранит прежний адрес у 3529 из 3535 — но восстановить
+-- ПО НЕМУ нельзя точно: самоидентифицирующий предикат «ls.source_url не
+-- realty.yandex» берёт 4832 строки, из которых наши только 3529; сузив его
+-- уникальностью URL, всё равно получаем 3529 наших + 9 чужих (это строки,
+-- чей listings.source_url канонизировала ещё 164 — вернуть им URL застройщика
+-- значило бы отменить чужую починку). Плюс 6 наших строк не покрыты вовсе
+-- (у 3 нет строки в listing_sources, у 3 там уже канонический адрес).
+-- Поэтому прежние значения сохраняются ЯВНО и поимённо — таблица ниже. Откат:
+--
+-- UPDATE listings l
+-- SET source_url = b.old_source_url
+-- FROM yandex_source_url_backfill_257 b
+-- WHERE l.id = b.listing_id
+-- AND l.source_url = 'https://realty.yandex.ru/offer/' || l.source_id || '/';
+--
+-- (второе условие — чтобы откат не затирал адрес, который к тому моменту записал
+-- кто-то другой; повторный прогон отката безвреден). Таблица маленькая
+-- (3535 строк) и одноразовая: когда откат больше не нужен, её можно просто
+-- удалить — на приложение она не влияет, читателей у неё нет.
+--
+-- ЧЕГО ЭТА МИГРАЦИЯ НЕ ДЕЛАЕТ, СОЗНАТЕЛЬНО:
+-- * не трогает `ON CONFLICT DO UPDATE` / reconcile в scraper_kit/base.py —
+-- дописывание source_url в апсерт это отдельное решение (прецедент #2818: там
+-- COALESCE в апсерте так же намеренно не трогали);
+-- * не трогает listing_sources.source_url — читателей у колонки нет (grep по
+-- app/: единственное обращение — тот самый INSERT), а в ней остаётся живая
+-- история того, что отдал gate-API;
+-- * не трогает строки с source_url IS NULL — их 0, а не «на всякий случай»
+-- (`!~` на NULL даёт NULL, такие строки предикат и так не берёт);
+-- * не гасит и не удаляет ни одной строки: меняется ровно одна колонка.
+--
+-- Dependencies: 002_core_tables.sql (listings), 133_listings_uq_source_source_id.sql
+-- (уникальность source_id, на ней держится «коллизий 0»), 164 (та же формула).
+-- Идемпотентность: по построению. Второй прогон видит 0 строк с непарсимым URL →
+-- UPDATE и INSERT берут пустое множество; CREATE TABLE IF NOT EXISTS + ON CONFLICT
+-- DO NOTHING делают повтор безопасным и при частичном откате.
+-- lock_timeout: блокирующего DDL здесь нет (гейт check-migration-lock-timeout.py
+-- про CREATE TABLE молчит), но UPDATE по «горячей» listings берёт ROW EXCLUSIVE, и
+-- ждать его выдачи за чужой ACCESS EXCLUSIVE-сессией — ровно та очередь перед
+-- приложением, из-за которой заведён #2752. Пусть лучше деплой упадёт громко.
+
+BEGIN;
+
+SET LOCAL lock_timeout = '5s';
+
+CREATE TABLE IF NOT EXISTS yandex_source_url_backfill_257 (
+ listing_id bigint PRIMARY KEY,
+ old_source_url text NOT NULL,
+ changed_at timestamptz NOT NULL DEFAULT now()
+);
+
+COMMENT ON TABLE yandex_source_url_backfill_257 IS
+ 'Прежние (застройщицкие) listings.source_url, переписанные миграцией 257 (#2838). '
+ 'Только для отката; читателей в приложении нет, удаляется без последствий.';
+
+INSERT INTO yandex_source_url_backfill_257 (listing_id, old_source_url)
+SELECT id, source_url
+ FROM listings
+ WHERE source = 'yandex'
+ AND source_url !~ '/offer/[0-9]+'
+ AND source_id ~ '^[0-9]+$'
+ON CONFLICT (listing_id) DO NOTHING;
+
+UPDATE listings
+ SET source_url = 'https://realty.yandex.ru/offer/' || source_id || '/'
+ WHERE source = 'yandex'
+ AND source_url !~ '/offer/[0-9]+'
+ AND source_id ~ '^[0-9]+$';
+
+COMMIT;
diff --git a/tradein-mvp/backend/data/sql/258_houses_imv_transient_attempts.sql b/tradein-mvp/backend/data/sql/258_houses_imv_transient_attempts.sql
new file mode 100644
index 00000000..12f72b4b
--- /dev/null
+++ b/tradein-mvp/backend/data/sql/258_houses_imv_transient_attempts.sql
@@ -0,0 +1,41 @@
+-- 258_houses_imv_transient_attempts.sql
+-- Счётчик подряд идущих временных отказов домовой оценки Авито (эпик #2674).
+--
+-- ЗАЧЕM. imv_status='transient_error' был состоянием БЕЗ ВЫХОДА: очередь
+-- backfill'а выбирает ровно один статус за прогон (only_status, по умолчанию
+-- 'pending'), и за всю историю (41 прогон, 26.06–11.08) ни один не был запущен
+-- с другим значением. На 12.08.2026 в этом статусе лежали 1390 домов, 1337 из
+-- них — с причиной «503/500 от tradein-browser:3000/fetch-json» или «All
+-- connection attempts failed», то есть с ИНФРАСТРУКТУРНОЙ причиной, которой
+-- больше нет (сайдкар починен #2698; за 7 суток до 12.08 в его access-логе
+-- 108 из 108 POST /fetch-json = 200).
+--
+-- Сервис теперь отдаёт часть пакета на повтор transient_error автоматически
+-- (house_imv_backfill._RETRY_QUEUE_SQL). Этот счётчик — условие ВЫХОДА из
+-- повтора: дом, падающий по своей причине, а не по инфраструктурной, перестаёт
+-- занимать слот пакета после _MAX_TRANSIENT_ATTEMPTS (3) подряд.
+--
+-- Наблюдаемость НЕ переименовывается: статус остаётся 'transient_error',
+-- прежние разрезы по imv_status/imv_error_reason работают как работали, а
+-- «застряли окончательно» — это
+-- SELECT count(*) FROM houses
+-- WHERE imv_status='transient_error' AND imv_transient_attempts >= 3;
+--
+-- Индекс не добавляем: houses_imv_status_idx (064) уже частичный по
+-- imv_status IN ('pending','transient_error') с сортировкой по
+-- last_imv_attempt_at — фильтр по счётчику остаётся остаточным условием на
+-- выборке в тысячи строк.
+
+BEGIN;
+
+SET LOCAL lock_timeout = '5s';
+
+ALTER TABLE houses
+ ADD COLUMN IF NOT EXISTS imv_transient_attempts smallint NOT NULL DEFAULT 0;
+
+COMMENT ON COLUMN houses.imv_transient_attempts IS
+ 'Сколько раз подряд домовая IMV-оценка падала в transient_error. '
+ 'Растёт только на transient_error, обнуляется успехом. '
+ '>= 3 — дом больше не берётся в автоматический повтор (эпик #2674).';
+
+COMMIT;
diff --git a/tradein-mvp/backend/data/sql/259_data_quality_drop_pct_cadastr.sql b/tradein-mvp/backend/data/sql/259_data_quality_drop_pct_cadastr.sql
new file mode 100644
index 00000000..16312e53
--- /dev/null
+++ b/tradein-mvp/backend/data/sql/259_data_quality_drop_pct_cadastr.sql
@@ -0,0 +1,145 @@
+-- 259_data_quality_drop_pct_cadastr.sql
+-- Purpose (#2674, третий показатель того же класса): убрать v_data_quality.pct_cadastr.
+--
+-- 214 убрала outliers_flagged, 216 — price_disagreements_count по одному доводу: ноль,
+-- гарантированный устройством системы, читается как «проверили — чисто», хотя честно он
+-- означает «мы это не считаем». pct_cadastr — третий такой же, поэтому и действие то же:
+-- не переключать источник, а снять показатель.
+--
+-- ── ЧИСЛА С ПРОДА (2026-08-13, точный count) ────────────────────────────────
+-- v_data_quality.pct_cadastr .................... 0.000000000000000000000000
+-- знаменатель витрины (listings_active) ......... 45 198 (в listings всего 99 304)
+-- listings.cadastral_number IS NOT NULL ......... 0 из 99 304 (и 0 из 45 198 активных)
+-- deals.cadastral_number ........................ 0 из 96 974
+-- houses.cadastral_number (DaData) .............. 2 648 из 9 468 ← ДРУГОЙ объект
+-- listings.building_cadastral_number ............ 30 970 из 99 304 ← ДРУГОЙ объект
+--
+-- ── ЭТО НЕ ДЕФЕКТ ИЗМЕРИТЕЛЯ (контроль на здоровом образце в тех же данных) ──
+-- Тот же CTE active_listings и тот же шаблон `count(*) WHERE IS NOT NULL * 100.0
+-- / NULLIF(count(*), 0)` в соседних строках витрины даёт 95.61% (pct_geocoded), 39.82%
+-- (pct_description), 65.10% (pct_year_built). Ровно 0% — про колонку, а не про арифметику.
+--
+-- ── ПОЧЕМУ НОЛЬ СТРУКТУРНЫЙ ─────────────────────────────────────────────────
+-- listings.cadastral_number — кадастр КВАРТИРЫ. Единственное место в коде, которое его
+-- вообще читает, — providers/cian/serp.py:886 (`offer.get("cadastralNumber")`); в парсерах
+-- avito/yandex/domclick/n1 слов cadastr/kadastr нет ни разу, то есть для ЧЕТЫРЁХ площадок
+-- из пяти ноль гарантирован НАШИМ кодом и о предметной области не говорит ничего. Пусто
+-- при этом везде, где мы этот номер храним (три таблицы выше) — то же уже записано в
+-- app/services/matching/houses.py: «площадки кадастр не отдают».
+--
+-- ── ПОЧЕМУ НЕЛЬЗЯ «ПОЧИНИТЬ ОДНОЙ СТРОКОЙ», ПЕРЕКЛЮЧИВ НА СОСЕДНЮЮ КОЛОНКУ ──
+-- Напрашивается считать по listings.building_cadastral_number (31.19% всего, 29.47% у
+-- активных). Под подписью «доля объявлений с кадастром» это НОВАЯ ложь вместо старой:
+-- * это кадастр ЗДАНИЯ, и в listings у него РОВНО ОДИН писатель — наш ночной KNN ≤50 м
+-- по локальному зеркалу ЕГРН (tasks/cadastral_geo_match.py:161; проверено `git grep`
+-- по origin/main: других INSERT/UPDATE этой колонки нет). Он не «тот же кадастр из
+-- другого места», а наша производная;
+-- * #2674 замерил ключ как неинъективный (656 из 3 260 значений накрывают >1 здание ГАР,
+-- 20.1%; 751 из 2 864 зданий получают >1 значение, 26.2%) и прямо запретил считать его
+-- идентичностью здания;
+-- * разброс по площадкам среди активных геокодированных (cian 33.8%, yandex 20.3%,
+-- avito 42.0%, domclick 49.7%) — про точность НАШИХ координат и охват зеркала по ЕКБ,
+-- а не про качество объявления.
+-- Переименовать подпись мало: честное имя было бы «доля объявлений, которым ночной KNN
+-- подобрал здание в 50 м» — это другой показатель, и заводить его надо отдельно и
+-- осознанно, а не под видом починки этого. Авторитетный кадастр здания у нас есть —
+-- houses.cadastral_number из DaData (2 648/9 468 домов), но он про ДОМА, а витрина считает
+-- ОБЪЯВЛЕНИЯ; подставить его в эту строку — снова назвать одно другим.
+--
+-- ── ЦЕНА ПРАВКИ ────────────────────────────────────────────────────────────
+-- Читателей у витрины в коде нет (grep по /app/app в живом backend-контейнере пуст;
+-- /api/v1/admin/scraper/data-quality считает свои метрики сам и кадастр не показывает
+-- вовсе) — это ручной psql-снимок. Зависимых объектов у view тоже нет (pg_depend по
+-- 'v_data_quality'::regclass, прод 13.08: 0 строк), поэтому CASCADE не нужен и не должен
+-- появиться: в этом продукте `DROP ... CASCADE` уже терял гранты FDW-пользователю (C3).
+--
+-- ── ПОРЯДОК И БЛОКИРОВКА ───────────────────────────────────────────────────
+-- CREATE OR REPLACE VIEW колонку УДАЛИТЬ не может → DROP VIEW → CREATE VIEW (тот же
+-- порядок, что 214/216). DROP VIEW берёт ACCESS EXCLUSIVE, поэтому `SET LOCAL
+-- lock_timeout` (см. scripts/check-migration-lock-timeout.py). В отличие от 222, которая
+-- обошлась CREATE OR REPLACE, здесь COMMENT ON VIEW надо выставить ЗАНОВО: DROP уносит
+-- комментарий вместе с объектом.
+--
+-- Тело SELECT скопировано из 222_db_audit_cleanup.sql (последний DDL; сверено с живым
+-- pg_get_viewdef на проде 13.08 — совпадает) минус строка pct_cadastr. Из CTE убран
+-- ставший ненужным cadastral_number: 222 завела явный список колонок ровно затем, чтобы
+-- view не держал column-level зависимость на то, чего не показывает.
+--
+-- Dependencies: 216_dead_code_sweep.sql (текст COMMENT ON VIEW), 222_db_audit_cleanup.sql
+-- (последний DDL v_data_quality).
+-- Apply after: 258_houses_imv_transient_attempts.sql
+-- Идемпотентно: DROP VIEW IF EXISTS + CREATE VIEW + COMMENT — повторный прогон даёт тот
+-- же результат.
+
+BEGIN;
+
+-- Ждём лок не дольше 5 s: сам DROP мгновенный, но ждущий ACCESS EXCLUSIVE встаёт в
+-- очередь ПЕРЕД новыми запросами (#2791/#2792).
+SET LOCAL lock_timeout = '5s';
+
+DROP VIEW IF EXISTS v_data_quality;
+
+-- DDL идентичен 222, минус строка pct_cadastr и минус cadastral_number в CTE.
+CREATE VIEW v_data_quality AS
+WITH active_listings AS (
+ SELECT id, lat, description, house_id_fk, is_active
+ FROM listings
+ WHERE is_active = true
+)
+SELECT
+ (SELECT count(*) FROM houses) AS houses_total,
+ (SELECT count(*) FROM houses h
+ WHERE EXISTS (SELECT 1 FROM house_sources hs WHERE hs.house_id = h.id)) AS houses_with_source,
+ (SELECT count(*) FROM houses h
+ WHERE EXISTS (SELECT 1 FROM house_sources hs
+ WHERE hs.house_id = h.id AND hs.ext_source = 'avito')) AS houses_with_avito,
+ (SELECT count(*) FROM houses h
+ WHERE EXISTS (SELECT 1 FROM house_sources hs
+ WHERE hs.house_id = h.id AND hs.ext_source LIKE 'cian%')) AS houses_with_cian,
+ (SELECT count(*) FROM houses h
+ WHERE EXISTS (SELECT 1 FROM house_sources hs
+ WHERE hs.house_id = h.id AND hs.ext_source = 'yandex')) AS houses_with_yandex,
+ (SELECT count(*) FROM (
+ SELECT house_id FROM house_sources GROUP BY house_id HAVING count(*) >= 2
+ ) sub) AS houses_2plus_sources,
+ (SELECT count(*) FROM (
+ SELECT house_id FROM house_sources GROUP BY house_id HAVING count(*) >= 3
+ ) sub) AS houses_3plus_sources,
+ (SELECT count(*) FROM active_listings) AS listings_active,
+ (SELECT count(*) FROM (
+ SELECT listing_id FROM listing_sources
+ WHERE listing_id IN (SELECT id FROM active_listings)
+ GROUP BY listing_id HAVING count(*) >= 2
+ ) sub) AS listings_dedup_2sources,
+ (SELECT count(*) FROM active_listings WHERE lat IS NOT NULL) * 100.0
+ / NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_geocoded,
+ (SELECT count(*) FROM active_listings WHERE description IS NOT NULL) * 100.0
+ / NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_description,
+ (SELECT count(*) FROM active_listings l
+ JOIN houses h ON h.id = l.house_id_fk
+ WHERE h.year_built IS NOT NULL) * 100.0
+ / NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_year_built,
+ NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'avito') AS avito_last_scrape_ago,
+ NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'cian') AS cian_last_scrape_ago,
+ NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'yandex') AS yandex_last_scrape_ago;
+
+-- Текст 216 + абзац про pct_cadastr. Выставляем заново, потому что DROP VIEW выше унёс
+-- прежний комментарий вместе с объектом.
+COMMENT ON VIEW v_data_quality IS
+ 'KPI-снимок для РУЧНЫХ psql-запросов. Читателей в коде нет (проверено #2674): '
+ '/api/v1/admin/scraper/data-quality считает свои метрики сам и этот view не трогает. '
+ '#2674: price_disagreements_count убран — у всех 89 699 объявлений ровно один '
+ 'источник, поэтому показатель структурно не мог быть ненулевым и ноль читался как '
+ '«расхождений нет» вместо «мы не сравниваем». listings_dedup_2sources оставлен '
+ 'намеренно: он ту же пустоту называет своим именем («объявлений с 2+ источниками»), '
+ 'ноль в нём — честный ответ, а не мнимое благополучие. '
+ '#2674 (мигр. 259): pct_cadastr убран по тому же доводу — считал '
+ 'listings.cadastral_number (кадастр КВАРТИРЫ), а его не отдаёт ни одна площадка: '
+ '0 из 99 304 объявлений, 0 из 96 974 deals, единственный читающий его парсер — '
+ 'cian/serp.py. Показатель НЕ переведён на listings.building_cadastral_number: та '
+ 'колонка — кадастр ЗДАНИЯ и на 100% производная нашего ночного KNN ≤50 м '
+ '(tasks/cadastral_geo_match.py), неинъективного как ключ здания (#2674: 20.1% '
+ 'значений накрывают >1 здание ГАР); под подписью «доля объявлений с кадастром» она '
+ 'мерила бы покрытие нашего геокодера, а не качество объявлений.';
+
+COMMIT;
diff --git a/tradein-mvp/backend/data/sql/260_houses_drop_has_panorama.sql b/tradein-mvp/backend/data/sql/260_houses_drop_has_panorama.sql
new file mode 100644
index 00000000..c53c6ce5
--- /dev/null
+++ b/tradein-mvp/backend/data/sql/260_houses_drop_has_panorama.sql
@@ -0,0 +1,179 @@
+-- 260_houses_drop_has_panorama.sql
+-- Issue #2674 (хвост) — снос houses.has_panorama: признака НЕТ в предметной области.
+--
+-- Dependencies: 031_houses_alter_yandex.sql (завела колонку),
+-- 154_market_contract_views.sql (внесла её в публичный контракт
+-- market.v_houses), 155_reader_grants_to_contract_views.sql (грант
+-- gendesign_reader на этот view).
+-- Apply after: 258_houses_imv_transient_attempts.sql
+-- Deploy order: код УЖЕ впереди схемы — писатель (_save_yandex_house_panorama),
+-- парсер (ValuationHouseMeta.has_panorama) и правило разрешения конфликтов
+-- (HOUSE_FIELD_PRIORITY) сняты тем же PR, что несёт этот файл. Обратный порядок
+-- (снести колонку, оставить писателя) давал бы падающий UPDATE на каждой оценке
+-- yandex_valuation — молча проглоченный, но с WARNING в логах.
+--
+-- ── ЧТО ЗА НОЛЬ И ПОЧЕМУ ЭТО НЕ ДЕФЕКТ ──────────────────────────────────────
+-- Колонка заполнялась `"Панорама" in body_text` по тексту страницы оценки Яндекса.
+-- external_valuations (source='yandex_valuation', raw_payload->'house'), 24.05–12.08.2026:
+-- страниц ............................................................. 1536
+-- has_panorama = true .................................................... 0
+-- has_panorama = false ................................................ 1536
+-- ключ отсутствует ....................................................... 0
+-- houses: 9468 строк, has_panorama непустых 12, из них true 0.
+--
+-- Это НЕ «метка переехала» и НЕ «путь записи оборван». Живая проверка боевым трактом
+-- 13.08.2026 (curl_cffi impersonate=chrome120 + прод-прокси, RealScraperConfig — тот же
+-- клиент, что у estimator.py; только чтение) взяла три адреса Екатеринбурга, все HTTP 200:
+-- Советская 51 ...... HTML 1 191 929 б — мета разобралась: 1974 г., 9 эт., панель,
+-- 2,50 м потолки, 46 объектов
+-- Парина 46/5 ....... HTML 1 185 458 б — 2020 г., 18 эт.
+-- Сурикова 47 ....... 1977 г., 5 эт., кирпич, 184 объекта
+-- Вхождений «анорам» (без учёта регистра) в ПОЛНОМ HTML: 0, 0, 0. Равно как panorama /
+-- 3D-тур / Виртуальн / Street — 0. Переехать в атрибут, data-*, JSON-стейт или иную
+-- вёрстку метка не могла: её нет в документе целиком. Словарь удобств дома на странице:
+-- «Дом 1974 года · 9 этажей · Панельное здание · 2,50 м потолки · Газ · Лифт ·
+-- Мусоропровод», причём с ЯВНЫМИ отрицаниями («Лифт отсутствует», «Мусоропровода нет») —
+-- будь панорама признаком дома, она печаталась бы в этом ряду и в отрицательной форме.
+--
+-- Ноль был механически гарантирован самим кодом и о предметной области не говорил
+-- ничего, кроме одного: измерять нечего. Третий вид нуля — НЕПРИМЕНИМО, лечится
+-- удалением, а не починкой разбора.
+--
+-- ОГОВОРКА ЧЕСТНОСТИ: сырой HTML прошлых сборов не хранится (raw_payload держит только
+-- body_len/items_count), поэтому «метка была и исчезла в мае» доказательно не
+-- опровергается. Но и положительных за всё окно 1536 страниц ноль — в измеренной
+-- истории её тоже не было.
+--
+-- ── ГЛАВНАЯ ЦЕНА: ЛОМАЕМ ПУБЛИЧНЫЙ КОНТРАКТ ────────────────────────────────
+-- has_panorama входит в market.v_houses (154), где сказано прямым текстом: «adding a
+-- column later is backward compatible, renaming/removing one is not». Это осознанное
+-- ломающее изменение контракта, а не недосмотр. Основание — консьюмер колонку не
+-- читает: `git grep has_panorama` вне tradein-mvp пуст (в т.ч.
+-- backend/app/services/etl/newbuilding_crossload.py, единственный живой читатель
+-- контракта, #976/#2130). Держать в публичном обещании поле, которое всегда false и
+-- никогда не станет ничем другим, — обещать данные, которых не существует.
+--
+-- CREATE OR REPLACE VIEW удалить колонку не умеет, поэтому view пересоздаётся:
+-- DROP VIEW → DROP COLUMN → CREATE VIEW. Порядок обязателен ещё и потому, что
+-- DROP COLUMN без CASCADE упрётся в зависимость view (проверено на проде: единственный
+-- зависимый объект — market.v_houses). CASCADE НЕ используем — он снёс бы и то, что
+-- появится позже, без единого слова в логе.
+--
+-- ГРАНТЫ ТЕРЯЮТСЯ ПРИ DROP VIEW (это уже кусало: C3, FDW-гранты после DROP ... CASCADE).
+-- На проде на market.v_houses висит GRANT SELECT для gendesign_reader (155) — он
+-- восстанавливается ниже явно, тем же стейтментом, что и в 155. Без этой строки
+-- внешний ETL получил бы permission denied на следующем же прогоне.
+--
+-- ── СТОИМОСТЬ БЛОКИРОВКИ И SET LOCAL lock_timeout ──────────────────────────
+-- ALTER TABLE ... DROP COLUMN берёт ACCESS EXCLUSIVE на houses. Удержание дёшево и не
+-- зависит от размера: PostgreSQL не переписывает heap, а помечает attisdropped в
+-- каталоге — единицы миллисекунд на 9468 строк. Дорого ОЖИДАНИЕ выдачи лока: ждущий
+-- ACCESS EXCLUSIVE встаёт в очередь ПЕРЕД новыми запросами, и за ним начинают ждать
+-- обычные SELECT приложения — ровно то, что 2026-08-07 положило деплой на 29 минут
+-- (#2791, #2792). Поэтому `SET LOCAL lock_timeout = '5s'` (снизу ограничено
+-- deadlock_timeout = 1 s на проде; на работу ПОД локом не влияет). Срабатывание =
+-- честный красный деплой через 5 секунд, миграция не помечается применённой.
+--
+-- IDEMPOTENCY / SAFETY:
+-- - DROP VIEW IF EXISTS + DROP COLUMN IF EXISTS + CREATE VIEW после DROP —
+-- безопасный re-run.
+-- - Без CASCADE.
+-- - Откат: колонку вернуть можно (ALTER TABLE houses ADD COLUMN has_panorama boolean),
+-- данные не восстановятся — восстанавливать нечего, все 12 непустых значений false.
+--
+-- Критерий приёмки (записан ДО применения):
+-- 1. Запись в _schema_migrations по имени этого файла (а не «деплой зелёный»).
+-- 2. information_schema.columns по houses: has_panorama отсутствует.
+-- 3. market.v_houses существует, has_panorama в нём нет, остальные 59 колонок на
+-- месте и в том же порядке (прод до правки: 60), SELECT count(*) отдаёт 9468+ строк.
+-- 4. information_schema.role_table_grants: gendesign_reader снова имеет SELECT на
+-- market.v_houses.
+
+BEGIN;
+
+-- Ограничивает ОЖИДАНИЕ лока, не работу под ним. Обоснование значения — в шапке
+-- и в .claude/rules/sql.md § lock_timeout.
+SET LOCAL lock_timeout = '5s';
+
+DROP VIEW IF EXISTS market.v_houses;
+
+ALTER TABLE houses DROP COLUMN IF EXISTS has_panorama;
+
+-- Пересоздание контракта БЕЗ has_panorama. Список колонок — копия 154 минус одна
+-- строка; он и есть обещание стабильности, поэтому выписан явно, без SELECT *.
+CREATE VIEW market.v_houses AS
+SELECT
+ id,
+ source,
+ ext_house_id,
+ url,
+ slug,
+ address,
+ full_address,
+ short_address,
+ lat,
+ lon,
+ geom,
+ year_built,
+ house_type,
+ house_class,
+ material_walls,
+ material_floors,
+ series_name,
+ total_floors,
+ total_units,
+ entrances,
+ flat_count,
+ is_emergency,
+ passenger_elevators,
+ cargo_elevators,
+ has_concierge,
+ closed_yard,
+ has_playground,
+ hot_water,
+ heat_supply_type,
+ gas_supply_type,
+ overlap_type,
+ parking_type,
+ infrastructure_summary,
+ infrastructure_walk_distance,
+ developer_name,
+ developer_key,
+ management_company_id,
+ rating,
+ reviews_count,
+ rating_score,
+ rating_string,
+ transport_accessibility_rate,
+ advantages,
+ banks,
+ builders,
+ houses_by_turn,
+ corpus_count,
+ commission_year,
+ commission_month,
+ total_area_ha,
+ cadastral_number,
+ house_fias_id,
+ yandex_jk_id,
+ yandex_jk_slug,
+ cian_internal_house_id,
+ cian_zhk_url,
+ raw_payload,
+ first_seen_at,
+ last_scraped_at
+FROM public.houses;
+
+COMMENT ON VIEW market.v_houses IS
+ 'Stable public contract over public.houses (#2130). Explicit column list is the '
+ 'stability promise — do not SELECT * against the base table from external '
+ 'consumers. raw_payload is included because it is read today by gendesign ETL '
+ '#976 (newbuilding_crossload.py); scraper-internal QC/status/validated_at '
+ 'bookkeeping columns are intentionally excluded. #2674 (хвост): has_panorama '
+ 'убрана из контракта вместе с колонкой — ломающее изменение, принятое осознанно '
+ '(0 true из 1536 страниц, признака нет на площадке, читателей вне tradein нет).';
+
+-- DROP VIEW уничтожил гранты — восстанавливаем ровно то, что дала 155.
+GRANT SELECT ON market.v_houses TO gendesign_reader;
+
+COMMIT;
diff --git a/tradein-mvp/backend/data/sql/261_listings_search_mv_drop_placeholder_columns.sql b/tradein-mvp/backend/data/sql/261_listings_search_mv_drop_placeholder_columns.sql
new file mode 100644
index 00000000..751f9e36
--- /dev/null
+++ b/tradein-mvp/backend/data/sql/261_listings_search_mv_drop_placeholder_columns.sql
@@ -0,0 +1,270 @@
+-- 261_listings_search_mv_drop_placeholder_columns.sql
+-- Issue #2857 (эпик #2674) — снос трёх колонок-заглушек из listings_search_mv:
+-- distance_to_metro_m, last_price_change, photos_count.
+--
+-- Dependencies: 050_search_optimization.sql (завела витрину и 6 индексов),
+-- 094_cadastral_unify.sql (последняя пересоздала витрину; её текст
+-- и есть текущее прод-определение, сверено с pg_matviews 13.08.2026 —
+-- расхождений нет), 088_scrape_schedules_seed_search_matview_refresh.sql
+-- (суточный REFRESH ... CONCURRENTLY).
+-- Apply after: 260_houses_drop_has_panorama.sql
+-- Deploy order: схема и код независимы — у трёх колонок НЕТ читателей, поэтому
+-- правки кода этот PR не несёт и порядок «миграция ↔ образ» безразличен.
+--
+-- ── ЧТО ЗА НОЛЬ ────────────────────────────────────────────────────────────
+-- Не потеря данных и не оборванный писатель: NULL прописан в самом определении
+-- витрины литералом. Четвёртый вид нуля — ОБЕЩАНИЕ В КОНТРАКТЕ БЕЗ РЕАЛИЗАЦИИ:
+-- имена зарезервировали в 050, реализацию не подключили никогда.
+--
+-- pg_stats по listings_search_mv, 13.08.2026 (45 310 строк):
+-- null_frac = 1.0 у 5 колонок: cadastral_number, district,
+-- distance_to_metro_m, last_price_change, photos_count.
+-- Сносим три. После применения колонок с null_frac = 1.0 останется 2
+-- (cadastral_number — живая колонка с писателем, просто площадки её не отдают,
+-- см. 216/search_query.py; district — вынесен решением владельца, ниже).
+--
+-- ЧИТАТЕЛЕЙ НОЛЬ — перепроверено на origin/main, не по памяти:
+-- `git grep -E "distance_to_metro_m|last_price_change|photos_count" origin/main`
+-- даёт 8 строк, и все 8 — сами файлы 050 и 094 (объявление + комментарий над ним).
+-- Ни бэкенда, ни фронта, ни тестов, ни скриптов. Отдельно проверено, что колонки
+-- не уезжают в ответ через звёздочку: `SELECT *` из listings_search_mv в репозитории
+-- НЕТ ни одного (единственный читатель — services/search_query.py, там явный
+-- список из 27 имён), и SQLAlchemy-рефлексии витрины тоже нет.
+--
+-- DISTRICT НЕ ТРОГАЕМ, хотя он такой же пустой. Он доехал дальше всех: его тянет
+-- services/search_query.py:138 и объявляет schemas/search_response.py:44
+-- (`district: str | None`), то есть API его ОТДАЁТ — всегда null. Снос = ломающее
+-- изменение контракта, решение владельца, вынесено отдельным пунктом в #2857.
+-- Здесь он воспроизводится байт-в-байт (`NULL::text AS district`).
+--
+-- ── ПОЧЕМУ DROP + CREATE, А НЕ ALTER ───────────────────────────────────────
+-- Материализованному представлению нельзя удалить колонку: ALTER MATERIALIZED VIEW
+-- такой формы не имеет, а ALTER TABLE ... DROP COLUMN на relkind='m' отказывает.
+-- Единственный путь — пересоздание, как в 094.
+--
+-- БЕЗ CASCADE. Зависимых объектов на проде ноль (проверено через pg_depend/pg_rewrite
+-- 13.08.2026: 0 строк). Если зависимость появится до применения — DROP упрётся и
+-- деплой честно покраснеет; CASCADE снёс бы её молча.
+--
+-- ── ГРАНТЫ: ЛОВУШКА, КОТОРАЯ ЗДЕСЬ НЕ СРАБАТЫВАЕТ, НО ПРИКРЫТА ─────────────
+-- DROP уносит ACL вместе с объектом — это уже кусало (C3, FDW-гранты после
+-- DROP ... CASCADE; 260 восстанавливала GRANT SELECT для gendesign_reader вручную).
+-- На listings_search_mv восстанавливать сегодня НЕЧЕГО, и это измерено, а не
+-- предположено:
+-- pg_class.relacl = {tradein=arwdDxt/tradein} — только владелец, ни одного
+-- стороннего grantee; column-level грантов нет; pg_default_acl пуст.
+-- (information_schema.role_table_grants по витрине пуст ВСЕГДА и ничего не
+-- доказывает: information_schema не показывает материализованные представления
+-- в принципе — смотреть надо relacl. Это и есть тот источник, где ловушку легко
+-- проглядеть.)
+-- Для сравнения: gendesign_reader имеет SELECT на listings и offer_price_history —
+-- на витрину ему не давали.
+-- Тем не менее ACL снимается и переигрывается ниже автоматически: между написанием
+-- файла и его применением на проде может пройти неделя, и ручной слепок к тому
+-- моменту протухнет молча. Снимок берётся в той же транзакции, что и DROP, поэтому
+-- врать не может.
+--
+-- ── ИНДЕКСЫ ────────────────────────────────────────────────────────────────
+-- Пересоздаются все 6 (прод, 13.08.2026 — совпадают с 050/094 один в один).
+-- UNIQUE listings_search_mv_id_idx (listing_id) обязателен: без него суточный
+-- REFRESH MATERIALIZED VIEW CONCURRENTLY (app/tasks/refresh_search_matview.py,
+-- расписание refresh_search_matview 03:00-04:00 UTC) упадёт с
+-- «cannot refresh materialized view concurrently ... no unique index».
+--
+-- ── ЦЕНА ПЕРЕСОЗДАНИЯ И БЛОКИРОВКА ─────────────────────────────────────────
+-- Транзакция держит ACCESS EXCLUSIVE на витрине от DROP до COMMIT, т.е. читатели
+-- ждут всё построение. Замер на проде (EXPLAIN ANALYZE тела витрины, 13.08.2026):
+-- сам SELECT 6.6 s на прогретом кэше; плюс 6 индексов (GIN tsv 19 МБ, GIN trgm
+-- 17 МБ, остальные мелочь) при maintenance_work_mem = 64 МБ — ориентир 30-60 s
+-- на всю транзакцию. Для сравнения, суточный CONCURRENTLY-рефреш укладывается в
+-- 9-17 s, но он делает вдвое больше работы (строит + сливает).
+-- Простой READ-трафика приемлем: за всё время жизни БД (pg_stat_database.stats_reset
+-- пуст, т.е. счётчики ни разу не сбрасывались) витрина видела 225 seq_scan и
+-- 63 idx_scan — а суточный CONCURRENTLY-рефреш сам по себе даёт по seq_scan в день.
+-- То есть /api/v1/search к ней практически не ходит, и трюк «собрать под временным
+-- именем + переименовать» (12 лишних строк ради миллисекунд вместо минуты) не нужен.
+--
+-- SET LOCAL lock_timeout = '5s' — ограничивает ОЖИДАНИЕ выдачи лока, не работу под
+-- ним (см. .claude/rules/sql.md § lock_timeout). Ждущий ACCESS EXCLUSIVE встаёт в
+-- очередь ПЕРЕД новыми запросами. Отдельный реальный конфликт здесь: если деплой
+-- попадёт в окно 03:00-04:00 UTC, DROP столкнётся с REFRESH ... CONCURRENTLY →
+-- честный красный деплой через 5 s, миграция не помечается применённой, повторный
+-- деплой пройдёт.
+--
+-- IDEMPOTENCY / SAFETY:
+-- - DROP MATERIALIZED VIEW IF EXISTS + CREATE — повторный прогон приводит к тому
+-- же состоянию (ценой ещё одного построения). Индексы создаются на заведомо
+-- новом объекте, поэтому без IF NOT EXISTS (как в 050/094).
+-- - Данных не теряем: витрина целиком выводима из listings/houses/listing_sources.
+-- - Откат: вернуть три строки `NULL::...` в определение и пересоздать тем же
+-- способом. Восстанавливать нечего — значений не существовало.
+--
+-- КРИТЕРИЙ ПРИЁМКИ (записан ДО применения):
+-- 1. Строка `261_listings_search_mv_drop_placeholder_columns.sql` в
+-- _schema_migrations (а не «деплой зелёный»).
+-- 2. Колонок в витрине 31 (было 34); distance_to_metro_m / last_price_change /
+-- photos_count отсутствуют; district на месте, тип text.
+-- 3. pg_matviews.definition не содержит подстроки 'distance_to_metro_m'.
+-- 4. Индексов 6, среди них UNIQUE listings_search_mv_id_idx.
+-- 5. pg_class.relacl витрины эквивалентен доприменительному (сегодня — владелец
+-- и никого больше).
+-- 6. SELECT count(*) FROM listings_search_mv отдаёт 40k+ строк.
+-- 7. Следующий ночной refresh_search_matview завершается status='done'
+-- (доказательство, что CONCURRENTLY не потерял UNIQUE-индекс).
+-- 8. Ответ /api/v1/search по-прежнему содержит ключ district (и не содержит
+-- удалённых — их там и не было).
+
+BEGIN;
+
+-- Ограничивает ОЖИДАНИЕ лока, не работу под ним. Обоснование — в шапке.
+SET LOCAL lock_timeout = '5s';
+
+-- ── 1. Снимок ACL ДО сноса ─────────────────────────────────────────────────
+-- aclexplode(NULL) даёт 0 строк — на витрине без явного ACL блок просто пуст.
+-- Владельца исключаем: CREATE вернёт его права сам.
+-- Колоночные гранты (pg_attribute.attacl) снимаются ОТДЕЛЬНОЙ веткой: они живут
+-- не в relacl, и первая редакция этого файла их молча теряла — поймано прогоном
+-- на одноразовой БД, а не рассуждением.
+CREATE TEMP TABLE _mv2857_acl ON COMMIT DROP AS
+SELECT
+ CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE a.grantee::regrole::text END AS grantee,
+ a.privilege_type,
+ a.is_grantable,
+ NULL::text AS column_name
+FROM pg_class c
+JOIN pg_namespace n ON n.oid = c.relnamespace
+CROSS JOIN LATERAL aclexplode(c.relacl) AS a
+WHERE n.nspname = 'public'
+ AND c.relname = 'listings_search_mv'
+ AND c.relkind = 'm'
+ AND a.grantee <> c.relowner
+UNION ALL
+SELECT
+ CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE a.grantee::regrole::text END,
+ a.privilege_type,
+ a.is_grantable,
+ quote_ident(att.attname)
+FROM pg_class c
+JOIN pg_namespace n ON n.oid = c.relnamespace
+JOIN pg_attribute att ON att.attrelid = c.oid AND att.attnum > 0 AND NOT att.attisdropped
+CROSS JOIN LATERAL aclexplode(att.attacl) AS a
+WHERE n.nspname = 'public'
+ AND c.relname = 'listings_search_mv'
+ AND c.relkind = 'm'
+ AND a.grantee <> c.relowner;
+
+-- ── 2. Пересоздание витрины без трёх заглушек ──────────────────────────────
+DROP MATERIALIZED VIEW IF EXISTS listings_search_mv;
+
+CREATE MATERIALIZED VIEW listings_search_mv AS
+SELECT
+ l.id AS listing_id,
+ l.source,
+ l.source_url,
+ l.address,
+ l.geom,
+ l.lat,
+ l.lon AS lng,
+ l.rooms,
+ l.area_m2 AS total_area,
+ l.floor,
+ l.total_floors,
+ l.price_rub,
+ l.price_per_m2,
+ l.cadastral_number,
+ l.is_active,
+ l.scraped_at,
+ -- House denorm
+ h.id AS house_id,
+ h.year_built,
+ h.house_class,
+ h.developer_name,
+ h.rating AS house_rating,
+ h.reviews_count AS house_ratings_count,
+ -- Cross-source aggregates
+ (SELECT count(*) FROM listing_sources ls WHERE ls.listing_id = l.id) AS source_count,
+ (SELECT array_agg(DISTINCT ext_source) FROM listing_sources ls WHERE ls.listing_id = l.id) AS sources,
+ (SELECT bool_or(ext_source = 'avito') FROM listing_sources ls WHERE ls.listing_id = l.id) AS has_avito,
+ (SELECT bool_or(ext_source = 'cian') FROM listing_sources ls WHERE ls.listing_id = l.id) AS has_cian,
+ (SELECT bool_or(ext_source = 'yandex_realty') FROM listing_sources ls WHERE ls.listing_id = l.id) AS has_yandex,
+ -- Price percentile within house
+ (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY ll.price_per_m2)
+ FROM listings ll
+ WHERE ll.house_id_fk = l.house_id_fk AND ll.is_active = true) AS house_median_ppm2,
+ -- Заглушка, оставленная СОЗНАТЕЛЬНО: district доезжает до схемы ответа API
+ -- (schemas/search_response.py), снос — ломающее изменение контракта, решение
+ -- владельца (#2857). Соседние distance_to_metro_m / last_price_change /
+ -- photos_count сняты здесь: у них не было ни одного читателя.
+ NULL::text AS district,
+ -- Trigram-ready columns
+ l.address AS address_trgm,
+ -- Aggregated tsv (description + address + developer_name)
+ to_tsvector('russian',
+ coalesce(l.description, '') || ' ' ||
+ coalesce(l.address, '') || ' ' ||
+ coalesce(h.developer_name, '')
+ ) AS tsv
+FROM listings l
+LEFT JOIN houses h ON h.id = l.house_id_fk
+WHERE l.is_active = true
+ AND COALESCE(l.canonical, true) = true;
+
+-- ── 3. Те же 6 индексов (050/094) ──────────────────────────────────────────
+-- UNIQUE — обязателен для REFRESH ... CONCURRENTLY, см. шапку.
+CREATE UNIQUE INDEX listings_search_mv_id_idx
+ ON listings_search_mv (listing_id);
+
+CREATE INDEX listings_search_mv_geom_idx
+ ON listings_search_mv USING GIST (geom);
+
+CREATE INDEX listings_search_mv_filters_idx
+ ON listings_search_mv (rooms, price_rub, total_area, scraped_at DESC);
+
+CREATE INDEX listings_search_mv_address_trgm_idx
+ ON listings_search_mv USING GIN (address_trgm gin_trgm_ops);
+
+CREATE INDEX listings_search_mv_tsv_idx
+ ON listings_search_mv USING GIN (tsv);
+
+CREATE INDEX listings_search_mv_sources_idx
+ ON listings_search_mv (has_avito, has_cian, has_yandex);
+
+-- ── 4. Возврат грантов, снятых в п.1 ───────────────────────────────────────
+-- Пусто, если сторонних grantee не было (сегодня — так). privilege_type приходит
+-- из системного каталога, поэтому подставляется как есть.
+-- Если у кого-то окажется колоночный грант ИМЕННО на снесённую колонку — GRANT
+-- упадёт на несуществующем имени, и это правильно: такой грант означает читателя,
+-- которого мы не нашли, и деплой обязан покраснеть, а не молча снести колонку.
+DO $$
+DECLARE
+ r record;
+BEGIN
+ FOR r IN SELECT grantee, privilege_type, is_grantable, column_name FROM _mv2857_acl LOOP
+ EXECUTE format(
+ 'GRANT %s%s ON TABLE public.listings_search_mv TO %s%s',
+ r.privilege_type,
+ CASE WHEN r.column_name IS NULL THEN '' ELSE ' (' || r.column_name || ')' END,
+ r.grantee,
+ CASE WHEN r.is_grantable THEN ' WITH GRANT OPTION' ELSE '' END
+ );
+ RAISE NOTICE 'listings_search_mv: возвращён GRANT % % для %',
+ r.privilege_type, coalesce('(' || r.column_name || ')', 'на витрину'), r.grantee;
+ END LOOP;
+END
+$$;
+
+-- ── 5. Статистика сразу, а не «когда-нибудь придёт autoanalyze» ────────────
+-- Иначе планировщик до первого автоанализа работает по пустым оценкам, а критерий
+-- приёмки по pg_stats нечем проверить.
+ANALYZE listings_search_mv;
+
+COMMENT ON MATERIALIZED VIEW listings_search_mv IS
+ 'Витрина поиска (/api/v1/search, 050/094). #2857: сняты три колонки-заглушки '
+ 'distance_to_metro_m / last_price_change / photos_count — литеральный NULL в '
+ 'определении, ноль читателей во всём репозитории. district оставлен намеренно: '
+ 'он объявлен в schemas/search_response.py, его снос — ломающее изменение '
+ 'контракта API и решение владельца. Единственный читатель витрины — '
+ 'services/search_query.py с ЯВНЫМ списком колонок; SELECT * по ней запрещён '
+ 'по той же причине, что и по market.v_houses.';
+
+COMMIT;
diff --git a/tradein-mvp/backend/data/sql/_manifest_applied.txt b/tradein-mvp/backend/data/sql/_manifest_applied.txt
index 71642631..6b449178 100644
--- a/tradein-mvp/backend/data/sql/_manifest_applied.txt
+++ b/tradein-mvp/backend/data/sql/_manifest_applied.txt
@@ -245,3 +245,8 @@
250_drop_duplicate_expires_at_index.sql
251_listings_drop_ceiling_height.sql
254_listings_backfill_avito_rating_glued_address.sql
+257_listings_backfill_yandex_source_url.sql
+258_houses_imv_transient_attempts.sql
+259_data_quality_drop_pct_cadastr.sql
+260_houses_drop_has_panorama.sql
+261_listings_search_mv_drop_placeholder_columns.sql
diff --git a/tradein-mvp/backend/tests/fixtures/backtest_baseline.json b/tradein-mvp/backend/tests/fixtures/backtest_baseline.json
index ce167d78..d156690c 100644
--- a/tradein-mvp/backend/tests/fixtures/backtest_baseline.json
+++ b/tradein-mvp/backend/tests/fixtures/backtest_baseline.json
@@ -8,7 +8,7 @@
},
"low": {
"coverage_pct": 82.09,
- "mape_pct": 13.2,
+ "mape_pct": 12.67,
"n": 276,
"n_covered": 220
},
@@ -26,22 +26,22 @@
],
"expected_sold": {
"overall": {
- "mape_pct": 13.18,
- "median_bias_pct": -3.71,
+ "mape_pct": 12.63,
+ "median_bias_pct": -3.74,
"n": 269,
"n_no_analogs": 0,
- "p25_pct": -16.11,
- "p75_pct": 8.92
+ "p25_pct": -15.17,
+ "p75_pct": 8.67
},
"per_rooms": {
"0": {
"label": "студия",
- "mape_pct": 19.38,
- "median_bias_pct": 18.1,
+ "mape_pct": 16.96,
+ "median_bias_pct": 16.96,
"n": 35,
"n_no_analogs": 0,
"p25_pct": 1.5,
- "p75_pct": 34.32
+ "p75_pct": 38.82
},
"1": {
"label": "1к",
@@ -50,50 +50,50 @@
"n": 93,
"n_no_analogs": 0,
"p25_pct": -14.43,
- "p75_pct": 6.98
+ "p75_pct": 8.15
},
"2": {
"label": "2к",
- "mape_pct": 18.26,
- "median_bias_pct": -12.2,
+ "mape_pct": 17.39,
+ "median_bias_pct": -11.71,
"n": 74,
"n_no_analogs": 0,
- "p25_pct": -24.35,
+ "p25_pct": -23.07,
"p75_pct": -0.36
},
"3": {
"label": "3к",
- "mape_pct": 9.34,
- "median_bias_pct": -3.08,
+ "mape_pct": 7.79,
+ "median_bias_pct": -4.05,
"n": 43,
"n_no_analogs": 0,
"p25_pct": -10.26,
- "p75_pct": 4.68
+ "p75_pct": 3.82
},
"4": {
"label": "4+",
- "mape_pct": 16.3,
- "median_bias_pct": 3.34,
+ "mape_pct": 16.38,
+ "median_bias_pct": -1.32,
"n": 24,
"n_no_analogs": 0,
"p25_pct": -14.91,
- "p75_pct": 15.11
+ "p75_pct": 15.41
}
},
"per_segment": {
"бизнес": {
- "mape_pct": 14.65,
+ "mape_pct": 13.65,
"median_bias_pct": -10.54,
"n": 46,
- "p25_pct": -22.93,
+ "p25_pct": -27.15,
"p75_pct": -1.31
},
"комфорт": {
- "mape_pct": 11.8,
- "median_bias_pct": -4.61,
+ "mape_pct": 10.14,
+ "median_bias_pct": -5.01,
"n": 101,
- "p25_pct": -17.07,
- "p75_pct": 6.04
+ "p25_pct": -15.49,
+ "p75_pct": 4.37
},
"премиум": {
"mape_pct": 68.92,
@@ -103,11 +103,11 @@
"p75_pct": -68.92
},
"эконом": {
- "mape_pct": 13.71,
- "median_bias_pct": 2.54,
+ "mape_pct": 14.2,
+ "median_bias_pct": 3.33,
"n": 115,
- "p25_pct": -9.8,
- "p75_pct": 25.5
+ "p25_pct": -8.69,
+ "p75_pct": 27.3
},
"элит": {
"mape_pct": 33.2,
diff --git a/tradein-mvp/backend/tests/matching/test_conflict_resolution.py b/tradein-mvp/backend/tests/matching/test_conflict_resolution.py
index 8e5e1163..4495830f 100644
--- a/tradein-mvp/backend/tests/matching/test_conflict_resolution.py
+++ b/tradein-mvp/backend/tests/matching/test_conflict_resolution.py
@@ -113,9 +113,17 @@ class TestYandexHousePriority:
assert "ceiling_height" not in HOUSE_FIELD_PRIORITY
- def test_house_has_panorama_yandex_valuation_only(self) -> None:
- out = resolve_house_field("has_panorama", {"yandex_valuation": True})
- assert out is True
+ def test_has_panorama_removed_from_house_priority(self) -> None:
+ """#2674 (хвост): правило снято вместе с колонкой houses.has_panorama (мигр. 259).
+
+ В отличие от ceiling_height выше, это правило было ИСПОЛНИМО — колонка
+ существовала, единственный источник её писал. Разрешать было нечего:
+ yandex_valuation отдавал False всегда (0 true из 1536 страниц на проде),
+ потому что слова «панорам» на странице оценки нет вовсе.
+ """
+ from app.services.matching.conflict_resolution import HOUSE_FIELD_PRIORITY
+
+ assert "has_panorama" not in HOUSE_FIELD_PRIORITY
def test_house_yandex_total_listings_yandex_valuation_only(self) -> None:
out = resolve_house_field("yandex_total_listings", {"yandex_valuation": 42})
@@ -217,7 +225,6 @@ class TestYandexListingPriority:
"corpus_count",
"total_area_ha",
"commission_month",
- "has_panorama",
"yandex_total_listings",
]
for key in yandex_keys:
diff --git a/tradein-mvp/backend/tests/skip_allowlist.txt b/tradein-mvp/backend/tests/skip_allowlist.txt
index 30011d7b..fe555229 100644
--- a/tradein-mvp/backend/tests/skip_allowlist.txt
+++ b/tradein-mvp/backend/tests/skip_allowlist.txt
@@ -59,3 +59,13 @@ tests/test_purge_expired_trade_in_data.py::test_real_purge_not_wedged_by_healthy
# дефолт mark_banned ('unknown') проходит CHECK-констрейнт из миграции 234:
# на мок-лэйне (deploy-tradein.yml, DSN-заглушка) констрейнта нет вовсе.
tests/test_2764_ban_kind_no_default.py::test_real_default_ban_kind_survives_the_check_constraint
+
+# Повтор застрявших transient_error (#2674, PR #2843) — тот же `_live_session()`.
+# Проверяют ВЫБОРКУ очереди на живой схеме (кто попал в пакет прогона), а не текст
+# SQL: на мок-лэйне deploy-tradein.yml БД нет вовсе. В ci-tradein.yml они бегут
+# по-настоящему (Postgres + схема из data/sql) — там прогон и был красным на
+# origin/main и зелёным на ветке. Статическая половина файла
+# (test_retry_queue_is_not_bound_to_only_status) БД не требует и идёт в обоих лэйнах.
+tests/test_house_imv_retry_stuck.py::test_explicit_only_status_still_takes_exhausted_houses
+tests/test_house_imv_retry_stuck.py::test_stuck_transient_house_returns_to_the_queue_by_itself
+tests/test_house_imv_retry_stuck.py::test_transient_attempts_counter_only_counts_transient
diff --git a/tradein-mvp/backend/tests/tasks/test_yandex_detail_backfill.py b/tradein-mvp/backend/tests/tasks/test_yandex_detail_backfill.py
index fb33b16e..d4d34cae 100644
--- a/tradein-mvp/backend/tests/tasks/test_yandex_detail_backfill.py
+++ b/tradein-mvp/backend/tests/tasks/test_yandex_detail_backfill.py
@@ -15,6 +15,7 @@ import json
import os
import re
import sys
+from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
@@ -25,6 +26,9 @@ sys.modules.setdefault("weasyprint", _wp_mock)
import pytest # noqa: E402
from app.tasks.yandex_detail_backfill import ( # noqa: E402
+ CANONICAL_URL_SQL,
+ OFFER_ID_PATTERN,
+ OFFER_URL_PATTERN,
YandexDetailBackfillResult,
run_yandex_detail_backfill,
)
@@ -55,13 +59,21 @@ def _make_snapshot(n: int) -> list[dict]:
]
-def _mock_db(snapshot: list[dict], unenrichable: int = 0) -> MagicMock:
- """Fake Session: execute() отдаёт снапшот через .mappings().all(), а
- .scalar_one() — размер отброшенной (непарсимой) части очереди."""
+def _mock_db(
+ snapshot: list[dict],
+ unenrichable: int = 0,
+ url_from_offer_id: int = 0,
+) -> MagicMock:
+ """Fake Session: execute() отдаёт снапшот через .mappings().all(), а .one() —
+ остаток очереди вне снапшота, РАЗБИТЫЙ по причине (адрес восстановим из
+ source_id / адресовать нечем)."""
db = MagicMock()
sel = MagicMock()
sel.mappings.return_value.all.return_value = snapshot
- sel.scalar_one.return_value = unenrichable
+ sel.one.return_value = SimpleNamespace(
+ url_from_offer_id=url_from_offer_id,
+ unenrichable_pending=unenrichable,
+ )
db.execute.return_value = sel
return db
@@ -510,3 +522,72 @@ async def test_queue_gate_matches_parser_gate_and_counts_rest() -> None:
assert result.unenrichable_pending == 3535
assert runs.mark_done.call_args.args[2]["unenrichable_pending"] == 3535
+
+
+# ---------------------------------------------------------------------------
+# «Непригодно» — ярлык, а не диагноз (2026-08-12)
+# ---------------------------------------------------------------------------
+
+
+def _render_canonical_sql(offer_id: str) -> str:
+ """Считает CANONICAL_URL_SQL как строку: '||' — конкатенация, source_id — значение."""
+ parts = [p.strip() for p in CANONICAL_URL_SQL.split("||")]
+ return "".join(offer_id if p == "source_id" else p.strip("'") for p in parts)
+
+
+def test_recovered_url_equals_producer_canonical_form() -> None:
+ """Адрес, вычисленный из source_id, — тот же, что пишет продюсер, и парсер его примет.
+
+ Шесть прогонов подряд unenrichable_pending равнялся ровно 3535 не потому, что
+ счётчик застыл (SELECT живой), а потому что множество замкнуто: продюсер после
+ #2235 таких строк больше не создаёт, а выйти оттуда нельзя — source_url пишется
+ только при вставке. Ярлык «непригодны» был неверен: у всех есть offerId, и по
+ собранному из него URL страница парсится (прод-проба 2026-08-12, 6/6).
+
+ Сторож держит ровно это: формула восстановления в SQL не должна разъехаться с
+ `_canonical_source_url` продюсера, а результат — пройти гейт парсера.
+ """
+ from scraper_kit.providers.yandex.serp import _canonical_source_url
+
+ offer_id = "7416316697684470413" # реальный source_id прод-строки с macroserver.ru
+ recovered = _render_canonical_sql(offer_id)
+
+ assert re.match(OFFER_ID_PATTERN, offer_id)
+ assert re.search(OFFER_URL_PATTERN, recovered), recovered
+ for stored_url, is_offer_url in _PROD_QUEUE_HEAD:
+ if is_offer_url:
+ continue # у этих сохранённый адрес уже канонический, чинить нечего
+ assert _canonical_source_url(stored_url, offer_id) == recovered, stored_url
+
+
+@pytest.mark.asyncio
+async def test_pending_counter_split_by_reason() -> None:
+ """Остаток очереди делится по ПРИЧИНЕ, и восстановимое не зовётся непригодным.
+
+ Одно число на две разные судьбы («адрес чиним» и «адресовать нечем») читается
+ как «тут делать нечего» — так 3535 квартир простояли неделю вне обогащения.
+ """
+ db = _mock_db([], unenrichable=0, url_from_offer_id=3535)
+ runs = MagicMock()
+ session_cls, _session = _make_session_ctx([])
+
+ with (
+ patch(_ASYNC_SESSION, session_cls),
+ patch(_RUNS, runs),
+ patch(_RESOLVE_PROXY_URL, _mock_resolve_proxy_url()),
+ ):
+ result = await run_yandex_detail_backfill(
+ db, run_id=43, params={"batch_size": 10, "budget_sec": 60}
+ )
+
+ assert result.url_from_offer_id == 3535
+ assert result.unenrichable_pending == 0
+ counters = runs.mark_done.call_args.args[2]
+ assert counters["url_from_offer_id"] == 3535
+ assert counters["unenrichable_pending"] == 0
+
+ # Снапшот-SELECT берёт такие строки в работу по вычисленному адресу, а не
+ # выбрасывает: без этой ветки они не попадут в очередь никогда.
+ snapshot_sql = str(db.execute.call_args_list[0].args[0])
+ assert "OR source_id ~ CAST(:offer_id_pattern AS text)" in snapshot_sql
+ assert CANONICAL_URL_SQL in snapshot_sql
diff --git a/tradein-mvp/backend/tests/test_2674_dead_admin_metrics.py b/tradein-mvp/backend/tests/test_2674_dead_admin_metrics.py
index ced39ac9..37d2df85 100644
--- a/tradein-mvp/backend/tests/test_2674_dead_admin_metrics.py
+++ b/tradein-mvp/backend/tests/test_2674_dead_admin_metrics.py
@@ -275,25 +275,62 @@ def test_migration_drops_every_dead_column() -> None:
assert "DROP COLUMN IF EXISTS is_outlier" in sql
+_VIEW_MARKER = re.compile(r"CREATE\s+(?:OR\s+REPLACE\s+)?VIEW\s+v_data_quality\b")
+
+
+def _latest_v_data_quality() -> tuple[str, str]:
+ """(текст последней миграции, создающей v_data_quality; тело её SELECT).
+
+ Ищем обе формы DDL (`CREATE VIEW` и `CREATE OR REPLACE VIEW`): миграция с парой
+ DROP+CREATE иначе оказалась бы невидимой, и тест продолжил бы проверять старую
+ миграцию, пока показатель уже вернулся в прод. Порядок = лексикографический:
+ деплой применяет файлы отсортированными, последний по имени — последний в проде.
+ """
+ creators = sorted(
+ p for p in _SQL_DIR.glob("*.sql") if _VIEW_MARKER.search(p.read_text("utf-8"))
+ )
+ assert creators, "не найдено ни одной миграции, создающей v_data_quality"
+ sql = creators[-1].read_text(encoding="utf-8")
+ hit = _VIEW_MARKER.search(sql)
+ assert hit is not None
+ return sql, sql[hit.end() :].split(";")[0]
+
+
def test_latest_v_data_quality_no_longer_reports_outliers() -> None:
- """Действующее определение v_data_quality (последняя миграция, которая его
- создаёт) не упоминает is_outlier.
+ """Действующее определение v_data_quality не упоминает is_outlier.
Red на origin/main: там последним был 095_dead_schema.sql со строкой
`(SELECT count(*) FROM listings WHERE is_outlier = true) AS outliers_flagged`
— показатель, который не мог быть ненулевым, потому что колонку не писал никто.
-
- Ищем обе формы DDL (`CREATE VIEW` и `CREATE OR REPLACE VIEW`): миграция с парой
- DROP+CREATE иначе оказалась бы невидимой, и тест продолжил бы проверять эту
- миграцию, пока показатель уже вернулся в прод. Порядок = лексикографический:
- деплой применяет файлы отсортированными, последний по имени — последний в проде.
"""
- marker = re.compile(r"CREATE\s+(?:OR\s+REPLACE\s+)?VIEW\s+v_data_quality\b")
- creators = sorted(p for p in _SQL_DIR.glob("*.sql") if marker.search(p.read_text("utf-8")))
- assert creators, "не найдено ни одной миграции, создающей v_data_quality"
- latest = creators[-1].read_text(encoding="utf-8")
- hit = marker.search(latest)
- assert hit is not None
- body = latest[hit.end() :].split(";")[0]
+ _, body = _latest_v_data_quality()
assert "outliers_flagged" not in body
assert "is_outlier" not in body
+
+
+def test_latest_v_data_quality_no_longer_reports_flat_cadastre() -> None:
+ """Тот же класс, третий случай: pct_cadastr (мигр. 259).
+
+ Считался по listings.cadastral_number — кадастру КВАРТИРЫ, которого не отдаёт ни
+ одна площадка (прод 13.08: 0 из 99 304 объявлений, 0 из 96 974 deals), поэтому
+ показатель не мог быть ненулевым, а «0.000000» рядом с pct_geocoded 95.61%
+ читался как измеренное качество данных.
+
+ Red на origin/main: последний DDL там — 222_db_audit_cleanup.sql, в нём строка
+ `... WHERE cadastral_number IS NOT NULL ... AS pct_cadastr` на месте.
+
+ Замена источника на listings.building_cadastral_number — НЕ починка: та колонка
+ про ЗДАНИЕ и целиком производная нашего ночного KNN ≤50 м, который #2674
+ замерил как неинъективный ключ здания. Поэтому тест запрещает и её появление
+ в этой витрине.
+ """
+ sql, body = _latest_v_data_quality()
+ assert "pct_cadastr" not in body, "показатель вернулся в v_data_quality"
+ assert "cadastral_number" not in body, (
+ "в витрину подставили другой кадастр — под подписью «доля объявлений с "
+ "кадастром» это новая ложь вместо старой (см. шапку 259)"
+ )
+ # DROP VIEW уносит COMMENT вместе с объектом — миграция, которая дропает, обязана
+ # выставить его заново, иначе объяснение «почему показателя нет» молча теряется.
+ if re.search(r"DROP\s+VIEW\s+(?:IF\s+EXISTS\s+)?v_data_quality\b", sql):
+ assert "COMMENT ON VIEW v_data_quality" in sql
diff --git a/tradein-mvp/backend/tests/test_2677_house_anchor_repair_basis.py b/tradein-mvp/backend/tests/test_2677_house_anchor_repair_basis.py
new file mode 100644
index 00000000..6a4ce8be
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_2677_house_anchor_repair_basis.py
@@ -0,0 +1,226 @@
+"""#2677: домовой IMV-якорь приводится к базису ремонта оцениваемой квартиры.
+
+Дефект: `median_price` к моменту blend'а уже домножен на `_repair_coefficient`
+(«требует ремонта» −6%, «евро» +10%), а домовой якорь из `house_imv_evaluations`
+запрошен у Avito с ОДНИМ ремонтом (`renovation_type`). Порог `anchor > median×1.15`
+и сам blend клали два разных базиса на одну шкалу — и, поскольку blend
+однонаправленный (только вверх), у клиента с «требует ремонта» это возвращало
+половину его −6% обратно наверх.
+
+Замер на проде (1061 персистированная оценка, 2026-08-10): blend не сработал ни
+разу (0 маркеров в confidence_explanation), но из 240 оценок с домовым якорем порог
+пересекали 19 — 9 из них «требует ремонта», и на них старый код давал медиану
+на 3.3–9.8 % выше базис-согласованной (суммарно +4.13 млн ₽).
+"""
+
+import os
+from typing import Any
+
+# Settings требует DATABASE_URL при инициализации (fail-fast, C-3).
+os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import anyio
+
+from app.services.estimator import _fetch_house_imv_anchor
+
+# `_anchor_repair_factor` импортируется ВНУТРИ тестов намеренно: на коде без правки
+# модуль обязан импортироваться, чтобы сквозные тесты ниже дошли до выполнения и
+# упали на ЦИФРАХ (красный по существу), а не на collection error.
+
+# Аналоги дают ровно 150 000 ₽/м² медианой → при area 40 м² pre-repair медиана 6 000 000 ₽.
+_PPM2 = 150_000.0
+_AREA = 40.0
+_BASE_MEDIAN = int(_PPM2 * _AREA)
+
+
+def _make_listing(price_per_m2: float) -> dict[str, Any]:
+ return {
+ "id": int(price_per_m2),
+ "source": "avito",
+ "address": "ЕКБ, ул. Учителей, 18",
+ "price_rub": int(price_per_m2 * _AREA),
+ "price_per_m2": price_per_m2,
+ "area_m2": _AREA,
+ "rooms": 1,
+ "floor": 4,
+ "total_floors": 16,
+ "lat": 56.838,
+ "lon": 60.595,
+ "listing_date": None,
+ "days_on_market": None,
+ "source_url": f"https://example.test/{int(price_per_m2)}",
+ "photo_urls": [],
+ "distance_m": 10.0,
+ }
+
+
+_ANALOGS = [
+ _make_listing(135_000.0),
+ _make_listing(140_000.0),
+ _make_listing(145_000.0),
+ _make_listing(_PPM2),
+ _make_listing(155_000.0),
+ _make_listing(160_000.0),
+ _make_listing(165_000.0),
+]
+
+
+def _run_estimate(anchor: dict[str, Any], repair_state: str | None) -> Any:
+ """estimate_quality со всеми I/O застабленными; домовой якорь форсирован."""
+ from app.schemas.trade_in import TradeInEstimateInput
+ from app.services.estimator import estimate_quality
+ from app.services.geocoder import GeocodeResult
+
+ payload = TradeInEstimateInput(
+ address="ЕКБ, ул. Учителей, 18",
+ area_m2=_AREA,
+ rooms=1,
+ floor=4,
+ total_floors=16,
+ repair_state=repair_state,
+ )
+ geo = GeocodeResult(
+ lat=56.838,
+ lon=60.595,
+ full_address="Свердловская обл., Екатеринбург, ул. Учителей, 18",
+ provider="nominatim",
+ )
+
+ async def _run() -> Any:
+ with (
+ patch("app.core.config.settings.estimate_hedonic_correction_enabled", new=False),
+ patch("app.services.estimator.geocode", new=AsyncMock(return_value=geo)),
+ patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
+ patch("app.services.estimator.match_house_readonly", return_value=None),
+ patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
+ patch(
+ "app.services.estimator._fetch_analogs",
+ return_value=(list(_ANALOGS), False, "S"),
+ ),
+ patch("app.services.estimator._fetch_deals", return_value=[]),
+ patch("app.services.estimator._fetch_dkp_corridor", return_value=None),
+ patch(
+ "app.services.estimator._get_or_fetch_imv_cached", new=AsyncMock(return_value=None)
+ ),
+ patch(
+ "app.services.estimator._get_or_fetch_yandex_valuation_cached",
+ new=AsyncMock(return_value=None),
+ ),
+ patch(
+ "app.services.estimator.estimate_via_cian_valuation",
+ new=AsyncMock(return_value=None),
+ ),
+ patch("app.services.estimator._get_asking_sold_ratio", return_value=(0.8, "per_rooms")),
+ patch("app.services.estimator._fetch_house_imv_anchor", return_value=anchor),
+ ):
+ return await estimate_quality(payload, MagicMock())
+
+ return anyio.run(_run)
+
+
+def _anchor(recommended: int, renovation: str | None) -> dict[str, Any]:
+ return {
+ "recommended_price": recommended,
+ "lower_price": int(recommended * 0.97),
+ "higher_price": int(recommended * 1.05),
+ "market_count": 500,
+ "rooms": 1,
+ "area_m2": _AREA,
+ "renovation_type": renovation,
+ }
+
+
+# ── чистая функция ───────────────────────────────────────────────────────────
+
+
+def test_repair_factor_is_ratio_of_the_same_coefficients() -> None:
+ """Множитель = coef(ремонт клиента) / coef(ремонт строки якоря), а не что-то новое."""
+ from app.services.estimator import _anchor_repair_factor
+
+ # строка 'cosmetic' (=standard, 1.00) → клиент «требует ремонта» (0.94)
+ assert _anchor_repair_factor("cosmetic", "needs_repair") == 0.94
+ # строка 'euro' (=good, 1.05) → клиент со стандартным ремонтом (1.00)
+ assert _anchor_repair_factor("euro", "standard") == 1.0 / 1.05
+ # строка 'required' (=needs_repair, 0.94) → клиент с евро (1.10)
+ assert _anchor_repair_factor("required", "excellent") == 1.10 / 0.94
+
+
+def test_repair_factor_unknown_anchor_renovation_falls_back_to_standard_basis() -> None:
+ """Незнакомый/пустой `renovation_type` считается стандартным базисом, а НЕ «не трогать».
+
+ Так строка и рождается: `house_imv_backfill._map_renovation_type` при неизвестном
+ ремонте дома шлёт Avito 'cosmetic' (=standard) как медиану популяции. Фолбэк в
+ 1.0 при дрейфе вокабуляра тихо вернул бы дефект — поэтому базис, а не no-op.
+ """
+ from app.services.estimator import _anchor_repair_factor
+
+ assert _anchor_repair_factor("что-то-новое", "needs_repair") == 0.94
+ assert _anchor_repair_factor(None, "needs_repair") == 0.94
+ # Неизвестен ремонт КЛИЕНТА — правки нет: медиана тоже осталась без коэффициента.
+ assert _anchor_repair_factor(None, None) == 1.0
+ assert _anchor_repair_factor("cosmetic", None) == 1.0
+ assert _anchor_repair_factor(None, "standard") == 1.0
+
+
+# ── SQL-граница: без колонки правка молча выродится в no-op ──────────────────
+
+
+def test_anchor_query_selects_renovation_type() -> None:
+ """`renovation_type` обязан быть в SELECT: без него `.get()` вернёт None → k=1.0.
+
+ Проверка строковая намеренно — это единственная точка, где видно, доедет ли
+ колонка из БД до `_anchor_repair_factor`; поштучный unit-тест по ту сторону
+ границы получает dict уже от вызывающего и такую регрессию не увидит.
+ """
+ db = MagicMock()
+ db.execute.return_value.mappings.return_value.first.return_value = None
+ _fetch_house_imv_anchor(db, target_house_id=11308, rooms=1, area=_AREA)
+ sql = str(db.execute.call_args[0][0])
+ assert "renovation_type" in sql.split("FROM house_imv_evaluations")[0]
+
+
+# ── сквозь estimate_quality: цена, которую видит пользователь ────────────────
+
+
+def test_needs_repair_client_not_lifted_by_cosmetic_anchor() -> None:
+ """«Требует ремонта»: якорь между старым и базис-согласованным порогом → blend НЕ идёт.
+
+ median = 6 000 000 × 0.94 = 5 640 000. Старый порог: 5 640 000 × 1.15 = 6 486 000
+ — якорь 6 700 000 его перекрывает и старый код поднимает медиану до 6 170 000.
+ Базис-согласованно: якорь 'cosmetic' в базисе «требует ремонта» = 6 298 000
+ < 6 486 000 → медиана остаётся 5 640 000 (−8.6 % к старому поведению).
+ """
+ est = _run_estimate(_anchor(6_700_000, "cosmetic"), "needs_repair")
+ assert est.median_price_rub == int(_BASE_MEDIAN * 0.94)
+ assert "скорректирована по оценке Avito IMV" not in (est.confidence_explanation or "")
+
+
+def test_euro_anchor_not_applied_raw_to_unknown_repair_client() -> None:
+ """Сценарий из #2677: дом переснят как «евро», ремонт клиента неизвестен.
+
+ median = 6 000 000 (coef 1.0). Старый порог 6 900 000 — якорь 7 000 000 его
+ перекрывает и поднимает медиану до 6 500 000. В базисе клиента якорь 'euro'
+ стоит 7 000 000 / 1.05 = 6 666 667 < 6 900 000 → медиана не двигается.
+ """
+ est = _run_estimate(_anchor(7_000_000, "euro"), None)
+ assert est.median_price_rub == _BASE_MEDIAN
+
+
+def test_blend_still_fires_and_reports_the_rebased_anchor() -> None:
+ """Правка не глушит механизм: якорь, крупный и после пересчёта, по-прежнему блендится.
+
+ median = 5 640 000, якорь 'cosmetic' 10 000 000 → в базисе клиента 9 400 000
+ > 6 486 000 → blend = (5 640 000 + 9 400 000) / 2 = 7 520 000. В пояснении —
+ ТО ЖЕ число, что ушло в расчёт, и пометка про пересчёт (иначе текст спорил бы
+ с карточкой, где показана сырая оценка Avito).
+ """
+ est = _run_estimate(_anchor(10_000_000, "cosmetic"), "needs_repair")
+ assert est.median_price_rub == (int(_BASE_MEDIAN * 0.94) + 9_400_000) // 2
+ explanation = est.confidence_explanation or ""
+ assert "9.4 млн ₽" in explanation
+ assert "приведённой к состоянию ремонта квартиры" in explanation
+ # Карточка Avito остаётся сырой — чужое число мы не правим.
+ assert est.avito_imv is not None
+ assert est.avito_imv.recommended_price == 10_000_000
diff --git a/tradein-mvp/backend/tests/test_2687_domclick_ban_kind.py b/tradein-mvp/backend/tests/test_2687_domclick_ban_kind.py
new file mode 100644
index 00000000..cef84cae
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_2687_domclick_ban_kind.py
@@ -0,0 +1,86 @@
+"""#2687: распознанный QRATOR-блок домкликового свипа — это 'platform', не 'unknown'.
+
+Ветка `if counters.blocked:` в `run_domclick_city_sweep` входится ТОЛЬКО когда
+скрейпер поднял `DomClickBlockedError` (площадка отдала QRATOR block-страницу). Это
+ровно определение `BAN_KIND_PLATFORM`. До правки свип звал `mark_banned` без
+`ban_kind`, и все его баны падали в дефолт 'unknown' (#2764) — диагноз был
+УСТАНОВЛЕН (блок распознан), но не передавался, и мониторинг не мог отличить
+«площадка отбила» (platform) от «тракт/прокси» (infra).
+
+Прод 2026-08-12: domclick_city_sweep 3751/3675/3594 все `banned` с `blocked=1`,
+`ban_kind='unknown'`. На старом коде тест ниже падает (ban_kind='unknown').
+"""
+
+from __future__ import annotations
+
+import os
+from types import SimpleNamespace
+from typing import Any
+from unittest.mock import AsyncMock, MagicMock, patch
+
+os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
+
+from scraper_kit.orchestration.pipeline import run_domclick_city_sweep
+
+PFX = "scraper_kit.orchestration.pipeline"
+
+
+class _BanKindRecorder:
+ """Ловит ban_kind, переданный в mark_banned (остальное — no-op)."""
+
+ def __init__(self) -> None:
+ self.ban_kind: str | None = None
+ self.marked: list[str] = []
+
+ def is_cancelled(self, db: Any, run_id: int) -> bool:
+ return False
+
+ def update_heartbeat(self, db: Any, run_id: int, counters: dict[str, Any]) -> None:
+ return None
+
+ def mark_done(self, db: Any, run_id: int, counters: dict[str, Any]) -> None:
+ self.marked.append("done")
+
+ def mark_failed(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None:
+ self.marked.append("failed")
+
+ def mark_banned(
+ self, db: Any, run_id: int, error: str, counters: dict[str, Any], **kw: Any
+ ) -> None:
+ self.marked.append("banned")
+ self.ban_kind = kw.get("ban_kind")
+
+
+async def _drive_blocked() -> _BanKindRecorder:
+ recorder = _BanKindRecorder()
+ scraper = MagicMock()
+ scraper.__aenter__ = AsyncMock(return_value=scraper)
+ scraper.__aexit__ = AsyncMock(return_value=None)
+ scraper.fetch_city = AsyncMock(return_value=[MagicMock() for _ in range(39)])
+ scraper.blocked = True # распознанный QRATOR-блок
+ scraper.geo_filtered = 0
+ scraper.fetch_errors = 1
+ scraper.buckets_completed = 2
+ scraper.buckets_total = 6
+ with (
+ patch(f"{PFX}.DomClickScraper", return_value=scraper),
+ patch(f"{PFX}.save_listings", MagicMock(return_value=(39, 0))),
+ patch(f"{PFX}.runs", recorder),
+ ):
+ await run_domclick_city_sweep(
+ MagicMock(),
+ config=SimpleNamespace(browser_http_endpoint="http://x:9000"),
+ matcher=MagicMock(),
+ run_id=1,
+ city_id=4,
+ pages=1,
+ request_delay_sec=0.0,
+ )
+ return recorder
+
+
+async def test_qrator_block_marks_platform_ban_kind() -> None:
+ """Блок распознан → ban_kind='platform'. На старом коде было 'unknown' (дефолт)."""
+ recorder = await _drive_blocked()
+ assert recorder.marked[-1] == "banned"
+ assert recorder.ban_kind == "platform"
diff --git a/tradein-mvp/backend/tests/test_2800_per_source_probe.py b/tradein-mvp/backend/tests/test_2800_per_source_probe.py
index 37fc10fe..7fa59d45 100644
--- a/tradein-mvp/backend/tests/test_2800_per_source_probe.py
+++ b/tradein-mvp/backend/tests/test_2800_per_source_probe.py
@@ -153,28 +153,38 @@ async def test_all_platforms_dead_still_marks_the_node(monkeypatch: pytest.Monke
@pytest.mark.parametrize(
- ("source", "must_contain"),
+ ("source", "must_contain", "path_suffix"),
[
- ("avito", "www.avito.ru"),
- ("cian", "ekb.cian.ru"),
- ("yandex", "realty.yandex.ru"),
+ ("avito", "www.avito.ru", "/robots.txt"),
+ ("cian", "ekb.cian.ru", "/robots.txt"),
+ ("yandex", "realty.yandex.ru", "/robots.txt"),
# apex-домен НЕ годится: через узел id=1 `domclick.ru/robots.txt` отдаёт 200,
# а рабочий bff-хост — 500. Проба по apex была бы зелёной и бесполезной.
- ("domclick", "bff-search-web.domclick.ru"),
+ #
+ # #2855: хоста мало — нужен ЗАЩИЩЁННЫЙ ПУТЬ. QRATOR закрывает /api/offers/*,
+ # robots.txt того же bff-хоста отдаётся свободно (это знал уже #2800 — см. п.4
+ # в шапке: «robots.txt площадка отдаёт и забаненному IP»), поэтому проба по
+ # нему зелёная ровно тогда, когда свип получает блок.
+ ("domclick", "bff-search-web.domclick.ru", "/api/offers/count/v1"),
],
)
async def test_probe_asks_the_working_host_of_each_source(
- monkeypatch: pytest.MonkeyPatch, source: str, must_contain: str
+ monkeypatch: pytest.MonkeyPatch, source: str, must_contain: str, path_suffix: str
) -> None:
seen: dict[str, Any] = {}
+ # Тело ответа зависит от площадки: у robots.txt-источников признак «ресурс отдан» —
+ # 'User-agent', у Домклика — ключ 'snippetsCount' живого count-ответа.
+ _body = (
+ '{"result":{"snippetsCount":678}}' if source == "domclick" else "User-agent: *
"
+ )
class _Resp:
status_code = 200
- text = '{"html": "User-agent: *
"}'
+ text = "{}"
@staticmethod
def json() -> dict[str, str]:
- return {"html": "User-agent: *
"}
+ return {"html": _body}
class _Client:
def __init__(self, **_kw: Any) -> None: ...
@@ -196,7 +206,10 @@ async def test_probe_asks_the_working_host_of_each_source(
assert ok is True
assert must_contain in seen["payload"]["url"]
- assert seen["payload"]["url"].endswith("/robots.txt") # нагрузки на площадку нет
+ # #2855: путь тоже сторожим — у robots.txt-источников он лёгкий, у Домклика это
+ # боевой count-эндпоинт (одно число в ответе, без пагинации и выдачи), то есть
+ # нагрузка на площадку остаётся минимальной, а защита — той же, что у работы.
+ assert path_suffix in seen["payload"]["url"]
# Инстанс сайдкара остаётся 'generic' — проба не отбирает лок у боевой сессии.
assert seen["payload"]["source"] == "generic"
@@ -233,7 +246,10 @@ async def test_stub_page_with_status_200_is_a_failure(monkeypatch: pytest.Monkey
assert ok is False
# Тракт узла исправен — виновата ПАРА: площадка не отдала ресурс этому exit-IP.
assert fail_kind == "page"
- assert "not robots.txt" in detail
+ # #2855: сообщение называет КОНКРЕТНЫЙ маркер, потому что он теперь зависит от
+ # площадки ('User-agent' у robots.txt-источников, 'snippetsCount' у Домклика).
+ # Прежний текст «not robots.txt» стал бы враньём там, где robots.txt и не просили.
+ assert "no marker 'User-agent'" in detail
async def test_stub_page_bans_the_pair(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -454,3 +470,72 @@ async def test_flaky_failure_does_not_ban_the_pair(monkeypatch: pytest.MonkeyPat
lease = acquire(db, "domclick") # type: ignore[arg-type]
assert lease is not None
release(db, lease.id) # type: ignore[arg-type]
+
+
+# ── 8. #2855: проба обязана делить с работой ЗАЩИЩЁННЫЙ ПУТЬ, а не только хост ──
+
+
+@pytest.mark.parametrize(
+ ("api_body", "expect_ok"),
+ [
+ # Блок: площадка отдаёт robots.txt свободно и закрывает /api/offers/* QRATOR'ом.
+ # На старом коде проба спрашивала robots.txt → 'User-agent' на месте → ok=True,
+ # то есть «пара здорова» ровно там, где свип получает блок. Красный на main.
+ ("Ошибкаqrator captcha", False),
+ # Контроль: живой ответ count-эндпоинта → ok=True. Держит двусторонность
+ # ВНУТРИ ветки: реализация «маркер не найден никогда» прошла бы первый случай
+ # и провалила бы этот, то есть тест не может позеленеть от глухого отказа.
+ # На origin/main этот случай тоже красный, но по другой причине — там проба
+ # спрашивает robots.txt, `ok` выходит True, и падает проверка АДРЕСА.
+ ('{"result":{"snippetsCount":678}}', True),
+ ],
+)
+async def test_domclick_probe_sees_the_block_that_robots_txt_hides(
+ monkeypatch: pytest.MonkeyPatch, api_body: str, expect_ok: bool
+) -> None:
+ """Площадка: robots.txt отдаёт всем, /api/offers/* закрывает. Проба обязана увидеть блок.
+
+ Замер прода 13.08.2026: 04:30 healthcheck `pair_banned=0` → 05:02 свип
+ `QRATOR block during rooms='1'` и узел 11 в бан. За сутки 64 проверки пар и
+ 5 банов на все четыре площадки при ежедневном блоке Домклика.
+ """
+ seen: dict[str, Any] = {}
+
+ class _Resp:
+ status_code = 200
+ text = "{}"
+
+ def __init__(self, body: str) -> None:
+ self._body = body
+
+ def json(self) -> dict[str, str]:
+ return {"html": self._body}
+
+ class _Client:
+ def __init__(self, **_kw: Any) -> None: ...
+
+ async def __aenter__(self) -> _Client:
+ return self
+
+ async def __aexit__(self, *_: object) -> None:
+ return None
+
+ async def post(self, url: str, json: dict[str, Any]) -> _Resp:
+ seen["payload"] = json
+ asked = str(json["url"])
+ # Площадка ведёт себя как в проде: лёгкий путь открыт, боевой — закрыт.
+ if asked.endswith("/robots.txt"):
+ return _Resp("User-agent: *
")
+ return _Resp(api_body)
+
+ monkeypatch.setattr(bf.httpx, "AsyncClient", _Client)
+ ok, fail_kind, _detail = await bf.probe_proxy_via_browser(
+ "http://tradein-browser:3000", "http://u:p@node:8080", source="domclick"
+ )
+
+ assert ok is expect_ok
+ if not expect_ok:
+ # Тракт узла исправен (200 пришёл) — негодна ПАРА, значит "page", не "proxy".
+ assert fail_kind == "page"
+ # Спрашивали именно боевой путь, а не robots.txt того же хоста.
+ assert "/api/offers/" in seen["payload"]["url"]
diff --git a/tradein-mvp/backend/tests/test_2857_search_mv_placeholder_columns.py b/tradein-mvp/backend/tests/test_2857_search_mv_placeholder_columns.py
new file mode 100644
index 00000000..9df5b583
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_2857_search_mv_placeholder_columns.py
@@ -0,0 +1,145 @@
+"""Витрина поиска не обещает колонок, которых не заполняет (#2857, эпик #2674).
+
+`listings_search_mv` с 050 несла четыре колонки, заданные литералом `NULL` прямо
+в определении: district, distance_to_metro_m, last_price_change, photos_count.
+Это не потеря данных и не оборванный писатель — имена зарезервировали, реализацию
+не подключили никогда. Три из них не читает НИКТО (ни бэкенд, ни фронт, ни тесты)
+и они сняты миграцией 261; district оставлен намеренно — он объявлен в
+schemas/search_response.py, то есть API его отдаёт, и его снос это ломающее
+изменение контракта (решение владельца, вынесено отдельно в #2857).
+
+Проверяется ФАКТ, а не текст: тест собирает СПИСОК КОЛОНОК витрины разбором её
+актуального определения (самый старший NN среди файлов, создающих витрину) и
+смотрит на состав списка. Переформатирование SQL, перестановка строк или смена
+`NULL::int` на `NULL::integer` тест не трогают; возврат колонки — краснит.
+
+Без БД и сети: миграции читаются как текст, разбираются в структуру.
+"""
+
+from __future__ import annotations
+
+import os
+import re
+from pathlib import Path
+
+os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
+
+SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql"
+
+MV = "listings_search_mv"
+# Сняты 261: ноль читателей во всём репозитории на момент сноса.
+DROPPED = ("distance_to_metro_m", "last_price_change", "photos_count")
+
+
+def _strip_sql_comments(sql: str) -> str:
+ sql = re.sub(r"/\*.*?\*/", " ", sql, flags=re.DOTALL)
+ return re.sub(r"--[^\n]*", "", sql)
+
+
+def _latest_definition() -> str:
+ """Текст файла с самым старшим NN, который создаёт витрину = её актуальный вид."""
+ creators = [
+ p
+ for p in SQL_DIR.glob("*.sql")
+ if re.search(
+ rf"CREATE\s+MATERIALIZED\s+VIEW\s+{MV}\b",
+ _strip_sql_comments(p.read_text("utf-8")),
+ re.I,
+ )
+ ]
+ assert creators, f"ни одна миграция не создаёт {MV} — тест смотрит не туда"
+ return max(creators, key=lambda p: int(p.name.split("_", 1)[0])).read_text("utf-8")
+
+
+def mv_columns() -> list[str]:
+ """Имена колонок витрины в порядке объявления.
+
+ Разбор: от `AS SELECT` до `FROM` на нулевой глубине скобок, разрез по запятым
+ той же глубины, имя колонки — последний идентификатор элемента (алиас после
+ `AS` либо хвост `l.foo`).
+ """
+ sql = _strip_sql_comments(_latest_definition())
+ body = re.split(rf"CREATE\s+MATERIALIZED\s+VIEW\s+{MV}\s+AS\s+SELECT\b", sql, flags=re.I)[1]
+
+ depth, items, cur = 0, [], []
+ for token in re.finditer(r"\(|\)|,|\bFROM\b|[^(),]+", body, re.I):
+ t = token.group(0)
+ if t == "(":
+ depth += 1
+ elif t == ")":
+ depth -= 1
+ elif depth == 0 and t == ",":
+ items.append("".join(cur))
+ cur = []
+ continue
+ elif depth == 0 and t.upper() == "FROM":
+ break
+ cur.append(t)
+ items.append("".join(cur))
+
+ return [item.split()[-1].split(".")[-1] for item in items if item.split()]
+
+
+def test_placeholder_columns_are_gone_from_the_matview() -> None:
+ """Red => витрина снова обещает поля, которых не заполняет (#2857).
+
+ Три колонки были литеральным `NULL` без единого читателя. Если тест покраснел
+ после возврата колонки — сначала заведи писателя, потом колонку, а не наоборот.
+ """
+ cols = mv_columns()
+ still_there = [c for c in DROPPED if c in cols]
+ assert not still_there, (
+ f"{MV} снова отдаёт колонки-заглушки {still_there}. Колонка без писателя "
+ "читается снаружи как «данные есть, просто у этого объекта пусто» — это "
+ "хуже мёртвого кода, потому что видно в контракте."
+ )
+
+
+def test_district_is_deliberately_kept() -> None:
+ """Red => district снесли заодно, а он в схеме ответа API.
+
+ schemas/search_response.py объявляет `district: str | None`, services/search_query.py
+ его тянет — снос ломает контракт /api/v1/search. Это решение владельца (#2857),
+ а не побочный эффект уборки соседних заглушек. Убирать — вместе со схемой ответа.
+ """
+ assert "district" in mv_columns(), (
+ f"district пропал из {MV}, а schemas/search_response.py его всё ещё объявляет: "
+ "ответ поиска начнёт падать/врать. Снимать поле — только вместе со схемой."
+ )
+
+
+def test_search_api_selects_only_columns_the_matview_has() -> None:
+ """Настоящий инвариант: то, что просит API, витрина обязана иметь.
+
+ Именно эта проверка отличает «список колонок» от «поиска подстроки»: она
+ краснеет на ЛЮБОЙ колонке, снесённой без правки читателя, а не только на трёх
+ известных именах.
+ """
+ from app.schemas.search import SearchParams
+ from app.services.search_query import build_search_query
+
+ sql, _ = build_search_query(SearchParams())
+ selected = [
+ c.strip() for c in sql[len("SELECT ") : sql.index(f" FROM {MV}")].split(",") if c.strip()
+ ]
+ missing = [c for c in selected if c not in mv_columns()]
+ assert not missing, (
+ f"services/search_query.py просит у {MV} колонки, которых в её определении нет: "
+ f"{missing}. Либо верни колонку в витрину, либо убери её из запроса И из "
+ "schemas/search_response.py."
+ )
+
+
+def test_unique_index_for_concurrent_refresh_survives_recreation() -> None:
+ """Red => ночной REFRESH ... CONCURRENTLY упадёт.
+
+ app/tasks/refresh_search_matview.py рефрешит витрину CONCURRENTLY (расписание
+ refresh_search_matview, 03:00-04:00 UTC). Без UNIQUE-индекса PostgreSQL отвечает
+ «cannot refresh materialized view concurrently ... no unique index» — а витрина,
+ которую пересоздали и забыли проиндексировать, молчит до самой ночи.
+ """
+ sql = _strip_sql_comments(_latest_definition())
+ assert re.search(rf"CREATE\s+UNIQUE\s+INDEX[^;]+ON\s+{MV}\s*\(\s*listing_id\s*\)", sql, re.I), (
+ f"в актуальном определении {MV} нет UNIQUE-индекса по listing_id — "
+ "REFRESH MATERIALIZED VIEW CONCURRENTLY без него невозможен."
+ )
diff --git a/tradein-mvp/backend/tests/test_930_scheduler_resume_checkpoint.py b/tradein-mvp/backend/tests/test_930_scheduler_resume_checkpoint.py
new file mode 100644
index 00000000..cc0ba37a
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_930_scheduler_resume_checkpoint.py
@@ -0,0 +1,295 @@
+"""#930 добивка: планировщик не подхватывал чекпоинт оборванного прогона.
+
+#930 сделал обе половины механизма — запись точки (`counters.done_buckets`, per-bucket
+heartbeat) и её чтение (`run_*_full_load(resume_run_id=...)`, skip-set в SERP-слое), —
+но единственным входом оставил админку. У avito full-load админского эндпоинта нет
+вовсе, а планировщик передавал `resume_run_id=None` ЛИТЕРАЛОМ (scheduler.py 708/728/799
+на origin/main). То есть боевой путь возобновления не существовал ни одного дня.
+
+Цена на проде (замер 2026-08-12, 90 суток, read-only): 433 корзины в 30 оборванных
+прогонах с ЖИВОЙ незабранной точкой — avito_full_load 242, cian_full_load 134,
+avito_full_load_exhaustive 57. Прогон 3547 (09.08, убит деплоем на третьем часу, 35 из
+84 корзин дерева) лежит до сих пор и будет подхвачен расписанием 139 16.08.
+
+Красный прогон на origin/main:
+ 1. `test_scheduler_hands_checkpoint_to_pipeline` — планировщик отдаёт в пайплайн
+ resume_run_id=None вместо id прошлого прогона (AssertionError на 3 источниках);
+ 2. `test_partial_bucket_is_not_complete` — бакет с выпавшей страницей приезжает в
+ on_bucket неотличимым от целого (у колбэка нет аргумента полноты вообще);
+ 3. `test_pipeline_keeps_partial_bucket_out_of_checkpoint` — TypeError: `_on_bucket`
+ на main принимает два аргумента, признаку полноты некуда приехать.
+Тесты ладдера (`_resume_decision`) на main падают с AttributeError — функции нет.
+"""
+
+from __future__ import annotations
+
+import os
+from types import SimpleNamespace
+from typing import Any
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
+
+from scraper_kit.orchestration import scheduler as sched
+from scraper_kit.orchestration.pipeline import run_avito_full_load
+from scraper_kit.providers.avito.serp import AvitoScraper
+
+PFX = "scraper_kit.orchestration.pipeline"
+
+# Прод-слепок расписания 139 (avito_full_load_exhaustive) на 2026-08-12: params прогона
+# 3547 совпадают с default_params расписания байт-в-байт — это и есть «то же задание».
+_PARAMS = {
+ "concurrency": 1,
+ "interval_days": 7,
+ "secondary_only": True,
+ "request_delay_sec": 7.0,
+ "price_cap_per_bucket": 1400,
+}
+
+
+def _candidate(**over: Any) -> SimpleNamespace:
+ """Строка-кандидат из _RESUME_CANDIDATE_SQL: прогон 3547 как он лежит на проде."""
+ base = {
+ "prev_id": 3547,
+ "prev_status": "cancelled",
+ "prev_counters": {
+ "unique_fetched": 5496,
+ "done_buckets": [f"room_1_komn:{i}:0" for i in range(35)],
+ },
+ "same_params": True,
+ "age_h": 164.6, # 6.86 суток — столько будет точке 3547 к подхвату 16.08
+ "interval_days": "7",
+ }
+ base.update(over)
+ return SimpleNamespace(**base)
+
+
+class _FakeDb:
+ """Двойник сессии: отдаёт ОДНУ строку-кандидата на любой SELECT, глотает UPDATE."""
+
+ def __init__(self, row: Any) -> None:
+ self.row = row
+ self.written: list[dict[str, Any]] = []
+
+ def execute(self, _stmt: Any, params: dict[str, Any] | None = None) -> Any:
+ if params and "counters" in params: # update_heartbeat пишет вердикт
+ self.written.append(params)
+ return MagicMock()
+ return MagicMock(fetchone=lambda: self.row)
+
+ def commit(self) -> None:
+ pass
+
+
+# ── 1. Главное: планировщик обязан отдать точку в пайплайн ───────────────────
+
+
+@pytest.mark.parametrize(
+ ("job", "pipeline_fn"),
+ [
+ (sched._job_avito_full_load, "run_avito_full_load"),
+ (sched._job_avito_full_load_exhaustive, "run_avito_full_load"),
+ (sched._job_cian_full_load, "run_cian_full_load"),
+ ],
+)
+async def test_scheduler_hands_checkpoint_to_pipeline(job: Any, pipeline_fn: str) -> None:
+ """Оборванный прогон с валидной точкой → новый прогон продолжает его, а не с нуля.
+
+ Падает на origin/main: планировщик передаёт литеральный None — 433 корзины за 90
+ суток перебирались заново, включая 35 корзин прогона 3547.
+ """
+ db = _FakeDb(_candidate())
+ captured: dict[str, Any] = {}
+
+ async def _spy(*_a: Any, **kw: Any) -> None:
+ captured.update(kw)
+
+ with patch.object(sched, pipeline_fn, _spy):
+ await job(db, 4000, dict(_PARAMS), MagicMock())
+
+ assert captured["resume_run_id"] == 3547
+
+
+async def test_verdict_lands_in_counters_of_new_run() -> None:
+ """Подхватили или нет — видно В СЧЁТЧИКАХ прогона, а не только в docker-логах.
+
+ Логи теряются при редеплое (контейнер tradein-scraper пересоздаётся), поэтому
+ молчаливый отказ подхватить неотличим от отсутствия правки.
+ """
+ db = _FakeDb(_candidate(prev_status="zombie"))
+ with patch.object(sched, "run_avito_full_load", AsyncMock()):
+ await sched._job_avito_full_load(db, 4000, dict(_PARAMS), MagicMock())
+
+ assert db.written, "вердикт о подхвате не записан в counters нового прогона"
+ written = db.written[-1]["counters"]
+ assert '"resume_reason": "status_zombie"' in written
+ assert '"resume_candidate": 3547' in written
+
+
+# ── 2. Ладдер отказов: у каждого нуля своя причина ───────────────────────────
+
+
+@pytest.mark.parametrize(
+ ("row", "reason"),
+ [
+ (None, "no_prev_run"),
+ (_candidate(prev_status="done"), "status_done"),
+ (_candidate(prev_status="zombie"), "status_zombie"),
+ (_candidate(same_params=False), "params_changed"),
+ (_candidate(prev_counters={"unique_fetched": 2977}), "no_checkpoint"),
+ (_candidate(age_h=200.0), "checkpoint_stale"),
+ (_candidate(prev_counters={"done_buckets": ["a"], "resume_chain": 2}), "chain_limit"),
+ ],
+)
+def test_resume_refusals_are_named(row: Any, reason: str) -> None:
+ """«Не подхватили» — это семь РАЗНЫХ фактов, и в counters они различимы."""
+ resume_id, verdict = sched._resume_decision(row)
+ assert resume_id is None
+ assert verdict["resume_reason"] == reason
+ assert verdict["resume_from"] is None
+
+
+def test_resume_chain_is_bounded() -> None:
+ """Цепочка возобновлений считается и упирается в потолок, а не тянется вечно.
+
+ Потолок выведен из STALE_DIGEST_INTERVAL_FACTOR (см. scheduler.py): полный обход
+ обязан начаться раньше, чем сводка объявит источник просроченным.
+ """
+ assert sched._MAX_RESUME_CHAIN == sched.STALE_DIGEST_INTERVAL_FACTOR - 1
+ _id, first = sched._resume_decision(_candidate())
+ assert first["resume_chain"] == 1
+ _id2, second = sched._resume_decision(
+ _candidate(prev_counters={"done_buckets": ["a"], "resume_chain": 1})
+ )
+ assert second["resume_chain"] == sched._MAX_RESUME_CHAIN
+ third_id, third = sched._resume_decision(
+ _candidate(prev_counters={"done_buckets": ["a"], "resume_chain": 2})
+ )
+ assert third_id is None and third["resume_reason"] == "chain_limit"
+
+
+def test_stale_threshold_follows_the_source_tick() -> None:
+ """Срок годности точки считается от такта ИСТОЧНИКА, а не общей константой.
+
+ cian ходит раз в 3 суток, avito — раз в 7; одна и та же точка возрастом 100 ч для
+ первого просрочена, для второго свежая. Плюс сутки — сетка запуска (см.
+ _resume_decision): 164.6 ч прогона 3547 при такте 7 суток обязаны пройти, иначе
+ точку отвергал бы jitter расписания, а пропущенный цикл (13 суток) — нет.
+ """
+ assert sched._resume_decision(_candidate(age_h=100.0, interval_days="3"))[0] is None
+ assert sched._resume_decision(_candidate(age_h=100.0, interval_days="7"))[0] == 3547
+ assert sched._resume_decision(_candidate(age_h=164.6, interval_days="7"))[0] == 3547
+ assert sched._resume_decision(_candidate(age_h=13 * 24.0, interval_days="7"))[0] is None
+
+
+# ── 3. Недособранный бакет не имеет права попасть в чекпоинт ─────────────────
+
+
+def _serp_config() -> SimpleNamespace:
+ return SimpleNamespace(
+ scraper_fetch_mode="curl_cffi",
+ browser_http_endpoint="http://browser.test/fetch",
+ scraper_proxy_url=None,
+ avito_proxy_max_rotations=0,
+ avito_serp_ok_not_banned=True,
+ avito_proxy_rotate_settle_s=0.0,
+ proxy_rotate_attempts=1,
+ proxy_rotate_attempt_timeout_s=1.0,
+ scraper_skip_seen_today=False,
+ )
+
+
+@pytest.mark.parametrize(
+ ("page2_html", "expected_complete"),
+ [(None, False), ("", True)],
+)
+async def test_partial_bucket_is_not_complete(
+ page2_html: str | None, expected_complete: bool
+) -> None:
+ """Страница 2 из 3 выпала → бакет НЕ «сделан»; все три пришли → «сделан».
+
+ Контрольная половина обязательна: реализация «всегда False» тоже прошла бы
+ одностороннюю проверку, но убила бы возобновление целиком.
+
+ Падает на origin/main: `on_bucket` вызывается двумя аргументами, признака полноты
+ в протоколе нет — частичный бакет неотличим от целого и попадает в done_buckets.
+ """
+ scraper = AvitoScraper(_serp_config())
+ scraper.request_delay_sec = 0.0
+ calls: list[tuple[str, bool]] = []
+
+ def _on_bucket(key: str, lots: list, complete: bool = True) -> None: # type: ignore[type-arg]
+ calls.append((key, complete))
+
+ async def _fetch_page(_self: Any, _slug: str, page: int, *_a: Any, **_k: Any) -> str | None:
+ return page2_html if page == 2 else f""
+
+ with (
+ patch.object(AvitoScraper, "_fetch_rooms_page_html", _fetch_page),
+ patch.object(
+ AvitoScraper,
+ "_parse_html",
+ lambda _self, html, **_k: [MagicMock(source_id=html, listing_segment="secondary")],
+ ),
+ ):
+ await scraper._paginate_leaf_bucket(
+ room_slug="kvartiry_1_komnatnye",
+ room_label="room_1_komn",
+ lo=0,
+ hi=3999999,
+ html="",
+ max_pages=3,
+ seen={},
+ price_cap_per_bucket=1400,
+ max_pages_per_bucket=100,
+ concurrency=2,
+ secondary_only=False,
+ on_bucket=_on_bucket,
+ skip_buckets=None,
+ expected_total=3 * 50,
+ )
+
+ assert [c[1] for c in calls] == [expected_complete]
+
+
+async def test_pipeline_keeps_partial_bucket_out_of_checkpoint() -> None:
+ """Пайплайн: лоты частичного бакета СОХРАНЕНЫ, но в чекпоинт он не попал.
+
+ Именно здесь «видимая потеря» (перескрап) не превращается в «невидимую»: пропустить
+ частичный бакет на следующем прогоне значит не перечитать его страницы уже никогда.
+ """
+ finals: list[dict[str, Any]] = []
+
+ class _Recorder:
+ def is_cancelled(self, *_a: Any, **_k: Any) -> bool:
+ return False
+
+ def update_heartbeat(self, *_a: Any, **_k: Any) -> None:
+ pass
+
+ def mark_done(self, _db: Any, _rid: int, counters: dict[str, Any]) -> None:
+ finals.append(dict(counters))
+
+ async def _fetch(*_a: Any, on_bucket: Any = None, **_k: Any) -> None:
+ on_bucket("room_1_komn:0:3999999", [MagicMock(source_id="a1")], True)
+ on_bucket("room_1_komn:4000000:4999999", [MagicMock(source_id="a2")], False)
+
+ scraper = MagicMock()
+ scraper.__aenter__ = AsyncMock(return_value=scraper)
+ scraper.__aexit__ = AsyncMock(return_value=None)
+ scraper.fetch_all_secondary = _fetch
+
+ with (
+ patch(f"{PFX}.AvitoScraper", return_value=scraper),
+ patch(f"{PFX}.save_listings", MagicMock(return_value=(1, 0))),
+ patch(f"{PFX}.runs", _Recorder()),
+ ):
+ counters = await run_avito_full_load(
+ MagicMock(), run_id=1, config=_serp_config(), matcher=MagicMock()
+ )
+
+ assert finals[0]["done_buckets"] == ["room_1_komn:0:3999999"]
+ assert finals[0]["partial_buckets"] == 1
+ assert counters.unique_fetched == 2, "лоты частичного бакета обязаны быть сохранены"
diff --git a/tradein-mvp/backend/tests/test_alerts_become_events.py b/tradein-mvp/backend/tests/test_alerts_become_events.py
index ae084d93..22324746 100644
--- a/tradein-mvp/backend/tests/test_alerts_become_events.py
+++ b/tradein-mvp/backend/tests/test_alerts_become_events.py
@@ -102,14 +102,32 @@ def test_harness_itself_drops_warnings() -> None:
class _FakeMonitorDB:
- """Session-мок мониторов свежести: один SELECT max(...)."""
+ """Session-мок мониторов свежести.
- def __init__(self, latest: date | None) -> None:
+ Монитор сделок спрашивает только max(...). Монитор СберИндекса (#2846) спрашивает
+ ещё время последнего ПОЛНОГО прогона загрузки и её такт — именно они, а не
+ календарный возраст периода, решают, быть ли тревоге.
+ """
+
+ def __init__(
+ self,
+ latest: date | None,
+ last_pull: datetime | None = datetime(2026, 8, 6, 5, 0, tzinfo=UTC),
+ interval_days: str = "7",
+ ) -> None:
self._latest = latest
+ self._last_pull = last_pull
+ self._interval_days = interval_days
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any:
+ sql = str(stmt)
result = MagicMock()
- result.first.return_value = MagicMock(latest=self._latest)
+ if "scrape_runs" in sql:
+ result.first.return_value = MagicMock(last_pull=self._last_pull)
+ elif "scrape_schedules" in sql:
+ result.first.return_value = MagicMock(interval_days=self._interval_days)
+ else:
+ result.first.return_value = MagicMock(latest=self._latest)
return result
def rollback(self) -> None:
@@ -122,10 +140,13 @@ def _patch_runs(monkeypatch: pytest.MonkeyPatch, module: Any) -> None:
monkeypatch.setattr(module.runs_mod, "mark_failed", lambda *a, **k: None)
-def test_sber_staleness_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None:
- """Прод-состояние (9 срабатываний, ноль событий): застой бенчмарка → событие."""
+def test_sber_pull_stall_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Загрузка встала (полный прогон 20 суток назад при такте 7) → событие.
+
+ 20 суток — реальный разрыв прод-истории между полными прогонами 07-17 и 08-06.
+ """
_patch_runs(monkeypatch, sber_mon)
- db = _FakeMonitorDB(date(2026, 6, 1))
+ db = _FakeMonitorDB(date(2026, 6, 1), last_pull=datetime(2026, 7, 17, 5, 38, tzinfo=UTC))
with glitchtip_events() as events:
out = sber_mon.check_sber_freshness(
db, # type: ignore[arg-type]
@@ -136,19 +157,24 @@ def test_sber_staleness_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None:
assert out["alert"] == 1
assert any(
"sber freshness" in t for t in event_texts(events)
- ), "устаревание СберИндекса не стало событием — WARNING до GlitchTip не долетает"
+ ), "отставание загрузки не стало событием — WARNING до GlitchTip не долетает"
-def test_sber_fresh_index_stays_silent(monkeypatch: pytest.MonkeyPatch) -> None:
- """Свежие данные — ни одного события (иначе алерт-усталость)."""
+def test_sber_old_period_with_healthy_pull_stays_silent(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Прод 2026-08-12: период старый (72 суток), но загрузка в такте — событий нет.
+
+ Это ровно то состояние, в котором main двенадцатые сутки подряд писал ERROR:
+ возраст там был лагом ПУБЛИКАЦИИ Сбера, а не нашим отставанием. Алерт-усталость
+ от таких событий и делает настоящий отказ незаметным.
+ """
_patch_runs(monkeypatch, sber_mon)
- db = _FakeMonitorDB(date(2026, 6, 1))
+ db = _FakeMonitorDB(date(2026, 6, 1)) # last_pull = 2026-08-06 (полный прогон)
with glitchtip_events() as events:
out = sber_mon.check_sber_freshness(
db, # type: ignore[arg-type]
run_id=2,
params={},
- now=datetime(2026, 6, 20, tzinfo=UTC),
+ now=datetime(2026, 8, 12, 19, 6, tzinfo=UTC),
)
assert out["alert"] == 0
assert event_texts(events) == []
@@ -165,7 +191,7 @@ def test_sber_empty_index_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None
params={},
now=datetime(2026, 8, 6, tzinfo=UTC),
)
- assert any("sber_price_index пуст" in t for t in event_texts(events))
+ assert any("у оценщика нет серии" in t for t in event_texts(events))
def test_deals_empty_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None:
diff --git a/tradein-mvp/backend/tests/test_backfill_wave2.py b/tradein-mvp/backend/tests/test_backfill_wave2.py
index 5ac78324..8a386aa4 100644
--- a/tradein-mvp/backend/tests/test_backfill_wave2.py
+++ b/tradein-mvp/backend/tests/test_backfill_wave2.py
@@ -500,7 +500,11 @@ async def test_backfill_house_imv_ok_path():
patch("app.services.house_imv_backfill.save_imv_result") as mock_save,
):
mock_mappings = MagicMock()
- mock_mappings.all.return_value = houses
+ # #2674: backfill делает ДВЕ выборки — сначала retry-очередь transient_error,
+ # затем основную по only_status. Фейк отвечает одним и тем же списком на любой
+ # SELECT, поэтому очередь повтора отдаём пустой явно — иначе один и тот же дом
+ # придёт в пакет дважды (артефакт фейка, в БД статусы не пересекаются).
+ mock_mappings.all.side_effect = [[], houses]
mock_db.execute.return_value.mappings.return_value = mock_mappings
result = await backfill_house_imv(mock_db, batch_size=10, request_delay_sec=0.0)
@@ -539,7 +543,11 @@ async def test_backfill_house_imv_no_params():
patch("app.services.house_imv_backfill._mark_status") as mock_mark,
):
mock_mappings = MagicMock()
- mock_mappings.all.return_value = houses
+ # #2674: backfill делает ДВЕ выборки — сначала retry-очередь transient_error,
+ # затем основную по only_status. Фейк отвечает одним и тем же списком на любой
+ # SELECT, поэтому очередь повтора отдаём пустой явно — иначе один и тот же дом
+ # придёт в пакет дважды (артефакт фейка, в БД статусы не пересекаются).
+ mock_mappings.all.side_effect = [[], houses]
mock_db.execute.return_value.mappings.return_value = mock_mappings
result = await backfill_house_imv(mock_db, batch_size=10, request_delay_sec=0.0)
@@ -593,7 +601,11 @@ async def test_backfill_house_imv_not_found():
patch("app.services.house_imv_backfill._mark_status") as mock_mark,
):
mock_mappings = MagicMock()
- mock_mappings.all.return_value = houses
+ # #2674: backfill делает ДВЕ выборки — сначала retry-очередь transient_error,
+ # затем основную по only_status. Фейк отвечает одним и тем же списком на любой
+ # SELECT, поэтому очередь повтора отдаём пустой явно — иначе один и тот же дом
+ # придёт в пакет дважды (артефакт фейка, в БД статусы не пересекаются).
+ mock_mappings.all.side_effect = [[], houses]
mock_db.execute.return_value.mappings.return_value = mock_mappings
result = await backfill_house_imv(mock_db, batch_size=10, request_delay_sec=0.0)
diff --git a/tradein-mvp/backend/tests/test_dead_code_sweep_2674.py b/tradein-mvp/backend/tests/test_dead_code_sweep_2674.py
index bdb6f976..5e6e0129 100644
--- a/tradein-mvp/backend/tests/test_dead_code_sweep_2674.py
+++ b/tradein-mvp/backend/tests/test_dead_code_sweep_2674.py
@@ -3,8 +3,6 @@
Каждая правка эпика — тест, который краснеет без неё:
подключено:
- - houses.has_panorama пишется из yandex_valuation (и НЕ пишется, когда страница
- не подтверждена — иначе false «не смотрели» выдаётся за false «посмотрели»);
- domrf_kapremont_load зарегистрирован Handler'ом И засеян в scrape_schedules —
именно отсутствие этой пары держало загрузчик ДОМ.РФ невызванным;
- filters_hash читается с estimation.sale.filtersHash, а не .data.filtersHash.
@@ -15,7 +13,11 @@
читателя и писателя;
- listings.merged_into, house_sources.raw_payload — колонки без писателя;
- v_data_quality.price_disagreements_count — показатель, который не мог быть
- ненулевым.
+ ненулевым;
+ - houses.has_panorama (хвост, мигр. 259) — признак, которого нет на площадке.
+ Первая редакция #2674 приняла его за оборванную проводку и ПОДКЛЮЧИЛА писателя;
+ подключённый писатель за 7 дней записал 12 значений, все false. Разница между
+ «проводка оборвана» и «мерить нечего» видна не по коду, а по площадке.
задокументировано:
- BROWSER_BLOCK_RESOURCES: код его не читает с #1812, но прод его задаёт —
@@ -29,19 +31,13 @@ from __future__ import annotations
import os
import re
from pathlib import Path
-from unittest.mock import MagicMock, patch
+from unittest.mock import MagicMock
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from scraper_kit.providers.cian.valuation import _parse_valuation_state
-from scraper_kit.providers.yandex.valuation import (
- ValuationHistoryItem,
- ValuationHouseMeta,
- YandexValuationResult,
-)
from app.services import estimator
-from app.services.estimator import _save_yandex_history_items
REPO_ROOT = Path(__file__).resolve().parents[3]
TRADEIN = REPO_ROOT / "tradein-mvp"
@@ -50,118 +46,7 @@ MIGRATION = SQL_DIR / "216_dead_code_sweep.sql"
# ─────────────────────────────────────────────────────────────────────────────
-# Подключено 1/3: houses.has_panorama
-# ─────────────────────────────────────────────────────────────────────────────
-
-
-def _result_with_meta(meta: ValuationHouseMeta) -> YandexValuationResult:
- return YandexValuationResult(
- address="Екатеринбург, ул. Куйбышева, 106",
- offer_category="APARTMENT",
- offer_type="SELL",
- page=1,
- source_url="https://realty.yandex.ru/otsenka-kvartiry-po-adresu-onlayn/?address=test",
- house=meta,
- history_items=[ValuationHistoryItem(area_m2=50.0, rooms=2, floor=5, start_price=9_000_000)],
- )
-
-
-def _panorama_updates(db: MagicMock) -> list[dict]:
- """Параметры всех db.execute, которые обновляли houses.has_panorama."""
- found = []
- for call in db.execute.call_args_list:
- sql = str(call.args[0])
- if "has_panorama" in sql and "UPDATE houses" in sql:
- found.append(call.args[1])
- return found
-
-
-def test_has_panorama_written_when_page_rendered() -> None:
- """Разобранный флаг доезжает до houses — до #2674 он не доезжал ни одной строкой."""
- db = MagicMock()
- result = _result_with_meta(
- ValuationHouseMeta(year_built=2010, total_floors=16, has_panorama=True)
- )
-
- with patch(
- "app.services.estimator.match_or_create_house",
- return_value=(99, 0.9, "fp"),
- ):
- _save_yandex_history_items(db, result)
-
- updates = _panorama_updates(db)
- assert updates, "houses.has_panorama не записан — вернулась исходная болячка #2674"
- assert updates[0] == {"hid": 99, "panorama": True}
-
-
-def test_has_panorama_false_written_when_page_rendered() -> None:
- """Отсутствие метки на ОТРИСОВАННОЙ странице — тоже наблюдение, пишем false."""
- db = MagicMock()
- result = _result_with_meta(
- ValuationHouseMeta(year_built=1998, total_floors=9, has_panorama=False)
- )
-
- with patch(
- "app.services.estimator.match_or_create_house",
- return_value=(7, 0.9, "fp"),
- ):
- _save_yandex_history_items(db, result)
-
- assert _panorama_updates(db) == [{"hid": 7, "panorama": False}]
-
-
-def test_has_panorama_written_when_page_has_no_history() -> None:
- """Отрисованная страница БЕЗ истории объявлений — ~10% случаев на проде.
-
- Ревью #2689: вызов стоял после раннего возврата по пустой истории, поэтому такие
- страницы молча пропускались (1519 оценок против 1360 домов с историей). Наблюдение
- о доме к наличию объявлений отношения не имеет.
- """
- db = MagicMock()
- result = _result_with_meta(
- ValuationHouseMeta(year_built=2015, total_floors=25, has_panorama=True)
- )
- result.history_items = []
-
- with patch(
- "app.services.estimator.match_or_create_house",
- return_value=(42, 0.9, "fp"),
- ):
- assert _save_yandex_history_items(db, result) == 0
-
- assert _panorama_updates(db) == [{"hid": 42, "panorama": True}]
-
-
-def test_has_panorama_not_written_when_page_unconfirmed() -> None:
- """Пустая мета (капча/редизайн) → NULL, а не сфабрикованный false."""
- db = MagicMock()
- result = _result_with_meta(ValuationHouseMeta(has_panorama=False))
-
- with patch(
- "app.services.estimator.match_or_create_house",
- return_value=(5, 0.9, "fp"),
- ):
- _save_yandex_history_items(db, result)
-
- assert _panorama_updates(db) == [], "false записан там, где мы ничего не наблюдали"
-
-
-def test_has_panorama_not_written_without_house_id() -> None:
- """Дом не сматчился → писать некуда, но и падать нельзя."""
- db = MagicMock()
- result = _result_with_meta(ValuationHouseMeta(year_built=2010, total_floors=16))
-
- with patch(
- "app.services.estimator.match_or_create_house",
- side_effect=RuntimeError("no house"),
- ):
- _save_yandex_history_items(db, result)
-
- assert _panorama_updates(db) == []
-
-
-# ─────────────────────────────────────────────────────────────────────────────
-# Подключено 2/3: загрузчик ДОМ.РФ — оборванная проводка
+# Подключено 1/2: загрузчик ДОМ.РФ — оборванная проводка
# ─────────────────────────────────────────────────────────────────────────────
@@ -217,7 +102,7 @@ def test_domrf_handler_reuses_loader_functions() -> None:
# ─────────────────────────────────────────────────────────────────────────────
-# Подключено 3/3: filters_hash лежит на уровень выше, чем его читали
+# Подключено 2/2: filters_hash лежит на уровень выше, чем его читали
# ─────────────────────────────────────────────────────────────────────────────
@@ -285,6 +170,10 @@ def test_dead_names_absent_from_live_code() -> None:
"asking_to_sold_ratios_tiered",
"asking_to_sold_tier_bounds",
"price_disagreements_count",
+ # Хвост #2674: has_panorama снят целиком — парсер, писатель, правило приоритета
+ # источников. Гейт краснеет на любом из трёх, а не только на одном (первая
+ # редакция как раз убрала не всё: парсер писал в поле, которого никто не пишет).
+ "has_panorama",
]
offenders: list[str] = []
for path in _live_python_sources():
@@ -332,6 +221,29 @@ def test_migration_drops_exactly_what_was_declared_dead() -> None:
assert "COMMENT ON VIEW v_price_divergence" in sql
+def test_has_panorama_dropped_together_with_the_contract_column() -> None:
+ """Снос колонки обязан пересобрать market.v_houses и вернуть ему грант.
+
+ has_panorama входила в публичный контракт (154), а CREATE OR REPLACE VIEW колонку
+ не удаляет — значит DROP VIEW, и вместе с ним теряются гранты. Тест держит все три
+ части в одном файле: колонки нет, контракт пересобран без неё, GRANT восстановлен.
+ """
+ sql = (SQL_DIR / "260_houses_drop_has_panorama.sql").read_text(encoding="utf-8")
+ assert "DROP VIEW IF EXISTS market.v_houses" in sql
+ assert "ALTER TABLE houses DROP COLUMN IF EXISTS has_panorama" in sql
+ assert "SET LOCAL lock_timeout" in sql, "блокирующий DDL без ограничения ожидания лока"
+
+ view_ddl = sql.split("CREATE VIEW market.v_houses AS", 1)[1].split(";", 1)[0]
+ assert "has_panorama" not in view_ddl, "контракт пересобран вместе со снесённой колонкой"
+ # Контракт — обещание стабильности: пересборка не должна заодно потерять соседей.
+ for kept in ("transport_accessibility_rate", "advantages", "raw_payload", "last_scraped_at"):
+ assert kept in view_ddl, f"{kept} пропала из контракта при пересборке"
+ assert "GRANT SELECT ON market.v_houses TO gendesign_reader" in sql, (
+ "DROP VIEW уничтожает гранты — без явного GRANT внешний ETL получит "
+ "permission denied на следующем прогоне"
+ )
+
+
def test_price_divergence_is_documented_as_structurally_empty() -> None:
"""Оставленный задел обязан говорить, чем он НЕ является сегодня."""
sql = MIGRATION.read_text(encoding="utf-8")
diff --git a/tradein-mvp/backend/tests/test_dkp_corridor_as_of_2846.py b/tradein-mvp/backend/tests/test_dkp_corridor_as_of_2846.py
new file mode 100644
index 00000000..85ed4502
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_dkp_corridor_as_of_2846.py
@@ -0,0 +1,208 @@
+"""#2846 — возраст выборки ДКП-коридора обязан доезжать до ответа.
+
+Плитка «ДКП · РОСРЕЕСТР (ФАКТИЧЕСКИЕ СДЕЛКИ)» несла count/low/median/high и
+period_months — ОКНО ПОИСКА. Возраста самих сделок в схеме не было вовсе, и
+слово «ФАКТИЧЕСКИЕ» читалось как «недавние».
+
+Замер прода 2026-08-12 (docker exec tradein-postgres psql -U tradein -d tradein):
+
+ deals source='rosreestr': 96 974 строки, max(deal_date) = 2026-01-01
+ (223 дня назад), 9 различных deal_date, day-of-month = 1 у 100% строк,
+ месяцы ровно {01, 04, 07, 10} → deal_date это метка КВАРТАЛЬНОЙ пачки.
+
+ Реплей выборок 881 реальной оценки за 90 суток (улица+город+rooms+area±15%
+ +окно 12 мес + ppm²-банды, включая city-wide widen):
+ свежайшая I кв. 2026 — 654 (74.2%)
+ коридора нет вовсе — 165 (18.7%)
+ свежайшая IV кв. 2025 — 62 ( 7.0%)
+ → у 8.7% выборок с коридором свежайшая сделка на КВАРТАЛ старше общего
+ максимума таблицы, т.е. max по всей таблице был бы враньём в их пользу.
+
+Красный прогон на origin/main: тесты падают на отсутствии latest_deal_date в
+dict/схеме (AttributeError / KeyError), а не на ожидании, списанном с настройки.
+"""
+
+from __future__ import annotations
+
+import os
+from datetime import date
+from typing import Any
+from unittest.mock import MagicMock
+
+os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
+
+from app.schemas.trade_in import AnalogLot, DkpCorridor
+from app.services.estimator import _fetch_dkp_corridor
+
+_ADDRESS = "Екатеринбург, ул. Гоголя, 18"
+
+
+def _deal(ppm2: int | None, deal_date: date | None) -> dict[str, Any]:
+ return {"price_per_m2": ppm2, "deal_date": deal_date}
+
+
+def _db(street_rows: list[dict[str, Any]], city_rows: list[dict[str, Any]] | None = None) -> Any:
+ """Session-двойник: 1-й execute — СберИндекс, 2-й — улица, 3-й — city-widen.
+
+ Порядок жёстко следует _fetch_dkp_corridor: сначала street-выборка, затем
+ _load_sber_index_series, затем (опционально) city-wide widen.
+ """
+ db = MagicMock()
+ calls: list[Any] = []
+
+ def _execute(query: Any, params: dict[str, Any] | None = None) -> MagicMock:
+ result = MagicMock()
+ calls.append(query)
+ sql = str(query)
+ if "sber" in sql.lower():
+ result.mappings.return_value.all.return_value = []
+ elif "deal_city_price_bands" in sql and "d.address ILIKE" in sql:
+ result.mappings.return_value.all.return_value = street_rows
+ elif "deal_city_price_bands" in sql:
+ result.mappings.return_value.all.return_value = city_rows or []
+ else:
+ result.mappings.return_value.all.return_value = []
+ return result
+
+ db.execute.side_effect = _execute
+ return db
+
+
+def _fetch(street_rows: list[dict[str, Any]], city_rows: list[dict[str, Any]] | None = None): # type: ignore[no-untyped-def]
+ return _fetch_dkp_corridor(
+ _db(street_rows, city_rows),
+ address=_ADDRESS,
+ rooms=2,
+ area=60.0,
+ city="екатеринбург" if city_rows is None else "нижний тагил",
+ )
+
+
+# ── КРАСНОЕ ЯДРО: возраст доезжает до схемы ─────────────────────────────────
+
+
+def test_latest_deal_date_reaches_the_response_schema() -> None:
+ """Главный красный: DkpCorridor обязан НЕСТИ дату свежайшей сделки.
+
+ На origin/main падает AttributeError — поля в схеме нет, а Pydantic v2
+ молча игнорирует лишний kwarg, так что «оно как-нибудь пролезет» — нет.
+ """
+ raw = _fetch([_deal(200_000, date(2025, 10, 1)), _deal(210_000, date(2026, 1, 1))])
+ assert raw is not None
+ corridor = DkpCorridor(**raw)
+ assert corridor.latest_deal_date == date(2026, 1, 1)
+
+
+def test_age_is_taken_from_the_selected_sample_not_the_table_max() -> None:
+ """Выборка кончается IV кв. 2025 → подпись обязана назвать IV кв. 2025.
+
+ Это те 7.0% реальных оценок из шапки модуля. Общий максимум таблицы
+ (2026-01-01) для них — чужая, более свежая дата.
+ """
+ raw = _fetch([_deal(190_000, date(2025, 7, 1)), _deal(195_000, date(2025, 10, 1))])
+ assert raw is not None
+ assert raw["latest_deal_date"] == date(2025, 10, 1)
+
+
+def test_deal_without_price_does_not_donate_its_freshness() -> None:
+ """Строка без ppm² не входит в границы коридора — не входит и в его возраст.
+
+ Иначе самая свежая, но выброшенная сделка омолаживала бы подпись, не
+ участвуя ни в одном из чисел под ней.
+ """
+ raw = _fetch([_deal(200_000, date(2025, 10, 1)), _deal(None, date(2026, 1, 1))])
+ assert raw is not None
+ assert raw["count"] == 1
+ assert raw["latest_deal_date"] == date(2025, 10, 1)
+
+
+def test_city_wide_widen_moves_the_age_together_with_the_numbers() -> None:
+ """#oblast-D widen: числа переехали на city-выборку — дата обязана тоже.
+
+ Street-выборка тут СВЕЖЕЕ (I кв. 2026) и её дата осталась бы приятнее для
+ глаза; но на экране после widen'а стоят city-числа, которые кончаются
+ III кв. 2025. Подпись описывает то, что показано.
+ """
+ street = [_deal(200_000, date(2026, 1, 1))]
+ city = [_deal(150_000, date(2025, 4, 1)), _deal(160_000, date(2025, 7, 1))]
+ raw = _fetch(street, city)
+ assert raw is not None
+ assert raw["count"] == 2, "widen должен был сработать (street n=1 < 3)"
+ assert raw["latest_deal_date"] == date(2025, 7, 1)
+
+
+def test_no_deals_means_no_corridor_and_nothing_to_date() -> None:
+ """count=0 → коридора нет вовсе; новое поле не создаёт повода что-то рисовать."""
+ assert _fetch([]) is None
+
+
+def test_period_months_is_not_a_freshness_claim() -> None:
+ """Регресс-якорь: окно поиска и возраст данных — РАЗНЫЕ числа.
+
+ Ровно та подмена, из-за которой v2 отказался печатать «за N месяцев»
+ (mappers.ts) — а v1 печатал.
+ """
+ raw = _fetch([_deal(200_000, date(2025, 10, 1))])
+ assert raw is not None
+ assert raw["period_months"] == 12
+ assert raw["latest_deal_date"] == date(2025, 10, 1)
+
+
+# ── PDF: то же обещание, та же подпись ──────────────────────────────────────
+
+
+def _estimate_with_deals(dates: list[date]) -> Any:
+ est = MagicMock()
+ est.actual_deals = [
+ AnalogLot(
+ address="Екатеринбург, ул. Гоголя",
+ area_m2=60.0,
+ rooms=2,
+ floor=None,
+ total_floors=None,
+ price_rub=12_000_000,
+ price_per_m2=200_000,
+ listing_date=d,
+ days_on_market=None,
+ date_precision="quarter",
+ source="rosreestr",
+ )
+ for d in dates
+ ]
+ return est
+
+
+def test_pdf_deals_page_dates_the_deals_it_shows() -> None:
+ """PDF §03 «ФАКТИЧЕСКИЕ СДЕЛКИ» — то же обещание, что и плитка.
+
+ До правки страница печатала «Период сделок: 08.2025 – 08.2026», где правый
+ конец = сегодня: окно поиска, выданное за период данных.
+ """
+ from app.services.exporters.trade_in_pdf import deals_as_of_label
+
+ assert deals_as_of_label(_estimate_with_deals([date(2025, 10, 1), date(2026, 1, 1)])) == (
+ "по I кв. 2026"
+ )
+ assert deals_as_of_label(_estimate_with_deals([])) is None
+
+
+def test_quarter_label_matches_every_deal_date_present_on_prod() -> None:
+ """Все 9 живых deal_date проекции 2026-08-12 → корректная римская метка.
+
+ Список не выдуман: это ровно `SELECT DISTINCT deal_date FROM deals`.
+ """
+ from app.services.exporters.trade_in_pdf import deals_as_of_label
+
+ expected = {
+ date(2026, 1, 1): "по I кв. 2026",
+ date(2025, 10, 1): "по IV кв. 2025",
+ date(2025, 7, 1): "по III кв. 2025",
+ date(2025, 4, 1): "по II кв. 2025",
+ date(2025, 1, 1): "по I кв. 2025",
+ date(2024, 10, 1): "по IV кв. 2024",
+ date(2024, 7, 1): "по III кв. 2024",
+ date(2024, 4, 1): "по II кв. 2024",
+ date(2024, 1, 1): "по I кв. 2024",
+ }
+ for d, label in expected.items():
+ assert deals_as_of_label(_estimate_with_deals([d])) == label
diff --git a/tradein-mvp/backend/tests/test_estimator_audit_fixes.py b/tradein-mvp/backend/tests/test_estimator_audit_fixes.py
index 6935c5fb..f6cb2430 100644
--- a/tradein-mvp/backend/tests/test_estimator_audit_fixes.py
+++ b/tradein-mvp/backend/tests/test_estimator_audit_fixes.py
@@ -331,18 +331,24 @@ def test_fix4_premium_comp_survives_post_weight_clip() -> None:
# ---------------------------------------------------------------------------
-# Fix 5a — sber staleness warning
+# Fix 5a — per-estimate sber staleness warning УДАЛЁН (#2846)
+#
+# Guard сравнивал возраст latest периода с sber_index_max_age_days=35. Такой
+# возраст недостижим по построению (period_month — метка первого числа + лаг
+# публикации ⇒ пол 46 суток), поэтому на проде warning писался у КАЖДОЙ оценки
+# и не нёс ни бита. Пара прежних тестов зеленела только на фикстуре с «свежим»
+# месяцем, которого в реальной серии не бывает. Свежесть теперь мерит одно место —
+# tasks/sber_freshness_monitor, по отставанию ЗАГРУЗКИ.
# ---------------------------------------------------------------------------
-def test_fix5a_stale_sber_logs_warning(caplog: pytest.LogCaptureFixture) -> None:
- """_load_sber_index_series логирует warning при stale серии."""
+def test_fix5a_no_per_estimate_staleness_warning(caplog: pytest.LogCaptureFixture) -> None:
+ """Серия отдаётся как есть; календарного warning'а в горячем пути больше нет."""
import logging
from app.services.estimator import _load_sber_index_series
- # Серия с единственным месяцем 2 года назад
- stale_month = date(2024, 1, 1)
+ stale_month = date(2024, 1, 1) # два года назад — прежний guard тут кричал
mock_db = MagicMock()
mock_db.execute.return_value.mappings.return_value.all.return_value = [
{"period_month": stale_month, "index_value_rub_m2": 100_000.0}
@@ -351,33 +357,9 @@ def test_fix5a_stale_sber_logs_warning(caplog: pytest.LogCaptureFixture) -> None
with caplog.at_level(logging.WARNING, logger="app.services.estimator"):
series = _load_sber_index_series(mock_db, region="Свердловская область")
- assert len(series) == 1
- assert stale_month in series
- # Warning о staleness должен быть залогирован
+ assert series == {stale_month: 100_000.0}
stale_msgs = [r for r in caplog.records if "stale" in r.message.lower()]
- assert stale_msgs, f"Ожидали warning о stale sber, caplog: {caplog.text}"
-
-
-def test_fix5a_fresh_sber_no_warning(caplog: pytest.LogCaptureFixture) -> None:
- """_load_sber_index_series НЕ логирует warning при свежей серии."""
- import logging
-
- from app.services.estimator import _load_sber_index_series
-
- # Текущий месяц (age=0..30 дней — точно свежее 35-дневного порога).
- today = datetime.now(tz=UTC).date()
- fresh_month = today.replace(day=1) # 1-е число ТЕКУЩЕГО месяца
- mock_db = MagicMock()
- mock_db.execute.return_value.mappings.return_value.all.return_value = [
- {"period_month": fresh_month, "index_value_rub_m2": 128_000.0}
- ]
-
- with caplog.at_level(logging.WARNING, logger="app.services.estimator"):
- series = _load_sber_index_series(mock_db, region="Свердловская область")
-
- assert len(series) == 1
- stale_msgs = [r for r in caplog.records if "stale" in r.message.lower()]
- assert not stale_msgs, f"Не ожидали stale warning для свежей серии, caplog: {caplog.text}"
+ assert not stale_msgs, f"per-estimate guard вернулся, caplog: {caplog.text}"
# ---------------------------------------------------------------------------
diff --git a/tradein-mvp/backend/tests/test_estimator_quarter_from_own_coords.py b/tradein-mvp/backend/tests/test_estimator_quarter_from_own_coords.py
new file mode 100644
index 00000000..d92a9ab9
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_estimator_quarter_from_own_coords.py
@@ -0,0 +1,216 @@
+"""Квартал ЦЕЛИ для индекса #764 берётся у цели, а не у аналога.
+
+Дефект (прод, замер 2026-08-12): `dadata.house_cadnum` пуст в 15 из 15 применений
+квартального индекса, и `target_quarter` молча брался из кадастра ПЕРВОГО аналога с
+кадастром. Аналог-донор стоял в 201-4087 м от цели; индексы кварталов на такой
+дистанции расходятся в 1.15-1.23 раза по медиане и в 1.6-1.8 по p90 — то есть
+«индекс квартала цели» на витрине был индексом квартала соседа, и он двигал цену
+(применённые на проде множители: 0.60 ×3, 0.73 ×2, 0.76 ×2, 0.79 ×2, 0.80, 1.03 ×3,
+1.08, 1.09).
+
+Паттерн: os.environ.setdefault перед импортом (как test_estimator_quarter_index.py).
+Фикстуры лотов/payload переиспользуются оттуда же; harness здесь свой, потому что
+тестам нужны РАЗНЫЕ индексы у разных кварталов (общий harness отдаёт один индекс
+всем кварталам → factor всегда 1.0, подмена квартала невидима) и контроль над
+координатным резолвом квартала цели.
+"""
+
+from __future__ import annotations
+
+import os
+from contextlib import ExitStack
+from typing import Any
+from unittest.mock import AsyncMock, MagicMock, patch
+
+os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
+
+import anyio
+
+from tests.test_estimator_quarter_index import (
+ _AREA,
+ _BASE_PPM2,
+ _make_fake_dadata,
+ _make_fake_geo_qi,
+ _make_listing_qi,
+ _make_payload_qi,
+)
+
+_TARGET_Q = "66:41:0204016"
+_NEIGH_CHEAP_Q = "66:41:0601022"
+_NEIGH_RICH_Q = "66:41:0601033"
+_INDEXES = {_TARGET_Q: 1.2, _NEIGH_CHEAP_Q: 0.8, _NEIGH_RICH_Q: 1.6}
+
+_UNPATCHED = object() # sentinel: координатный резолвер не подменяем
+
+
+def _run_estimate(
+ *,
+ analogs: list[dict[str, Any]],
+ dadata_cadnum: str | None,
+ coords_quarter: Any = _UNPATCHED,
+ coords_enabled: bool = True,
+):
+ """estimate_quality с полным stub-пачем I/O; индексы кварталов — из _INDEXES.
+
+ coords_quarter:
+ * `_UNPATCHED` (default) — координатный резолвер НЕ подменяется, db-мок
+ отдаёт «зданий рядом нет» (`.first() → None`), т.е. свой квартал цели
+ неизвестен. Этот режим исполним и на старом коде (там резолвера ещё нет) —
+ старый код подставит квартал аналога, на чём тест и падает.
+ * str / None — явная подмена резолвера.
+
+ coords_enabled — значение `estimate_quarter_from_coords_enabled` на время
+ прогона (в проде флаг ВЫКЛЮЧЕН, см. config); тесты координатной половины
+ включают его явно.
+ """
+ from app.services.estimator import estimate_quality, settings
+
+ db = MagicMock()
+ # «Ни одного здания в радиусе порога»: единственный db.execute(...).first() на
+ # пути оценки (всё остальное I/O застаблено) — координатный резолв квартала.
+ db.execute.return_value.first.return_value = None
+
+ dadata_obj = _make_fake_dadata(dadata_cadnum) if dadata_cadnum is not None else None
+
+ def _fake_lookup_index(db_arg, *, quarter_cad_number, min_n_deals):
+ idx = _INDEXES.get(quarter_cad_number)
+ return (idx, 30) if idx is not None else None
+
+ def _fake_lookup_indexes(db_arg, *, quarter_cad_numbers, min_n_deals):
+ return {q: _INDEXES[q] for q in quarter_cad_numbers if q in _INDEXES}
+
+ async def _run():
+ stubs = [
+ patch(
+ "app.services.estimator.geocode",
+ new=AsyncMock(return_value=_make_fake_geo_qi()),
+ ),
+ patch(
+ "app.services.estimator.dadata_clean_address",
+ new=AsyncMock(return_value=dadata_obj),
+ ),
+ patch("app.services.estimator.match_house_readonly", return_value=None),
+ patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
+ patch(
+ "app.services.estimator._fetch_analogs",
+ return_value=(list(analogs), False, "W"),
+ ),
+ patch("app.services.estimator._fetch_deals", return_value=[]),
+ patch("app.services.estimator._fetch_dkp_corridor", return_value=None),
+ patch(
+ "app.services.estimator._get_or_fetch_imv_cached",
+ new=AsyncMock(return_value=None),
+ ),
+ patch(
+ "app.services.estimator._get_or_fetch_yandex_valuation_cached",
+ new=AsyncMock(return_value=None),
+ ),
+ patch(
+ "app.services.estimator.estimate_via_cian_valuation",
+ new=AsyncMock(return_value=None),
+ ),
+ patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
+ patch("app.services.estimator._fetch_house_imv_anchor", return_value=None),
+ patch("app.services.estimator._lookup_quarter_index", side_effect=_fake_lookup_index),
+ patch(
+ "app.services.estimator._lookup_quarter_indexes",
+ side_effect=_fake_lookup_indexes,
+ ),
+ ]
+ if coords_quarter is not _UNPATCHED:
+ stubs.append(
+ patch(
+ "app.services.estimator._lookup_target_quarter_by_coords",
+ return_value=coords_quarter,
+ )
+ )
+ stubs.append(patch.object(settings, "estimate_quarter_from_coords_enabled", coords_enabled))
+ with ExitStack() as stack:
+ for stub in stubs:
+ stack.enter_context(stub)
+ return await estimate_quality(_make_payload_qi(), db)
+
+ return anyio.run(_run)
+
+
+def _analogs_two_quarters() -> list[dict[str, Any]]:
+ """5 аналогов в дешёвом квартале + 5 в дорогом, одинаковая цена ₽/м².
+
+ Первым в listings_clean идёт дешёвый — именно его кадастр старый код брал за
+ кадастр ЦЕЛИ. avg_analog_index = (0.8 + 1.6) / 2 = 1.2 при равных весах.
+ Разные этажи обязательны: физический ключ _dedup_cross_source иначе схлопнет
+ все лоты в один.
+ """
+ cheap = [
+ _make_listing_qi(building_cadastral_number=f"{_NEIGH_CHEAP_Q}:{100 + i}", floor=2 + i)
+ for i in range(5)
+ ]
+ rich = [
+ _make_listing_qi(building_cadastral_number=f"{_NEIGH_RICH_Q}:{200 + i}", floor=8 + i)
+ for i in range(5)
+ ]
+ return cheap + rich
+
+
+def test_neighbour_cadastre_no_longer_sets_target_quarter() -> None:
+ """Свой квартал цели неизвестен → поправки нет (а не «квартал первого аналога»).
+
+ КРАСНЫЙ на старом коде: там target_quarter = 66:41:0601022 (дешёвый сосед,
+ index 0.8), avg = 1.2 → factor 0.667 → медиана падает на треть, и пользователю
+ пишется «Учтена локация квартала (индекс цен квартала ×0.67)».
+ """
+ est = _run_estimate(analogs=_analogs_two_quarters(), dadata_cadnum=None)
+
+ assert est.median_price_rub == round(_BASE_PPM2 * _AREA)
+ assert "индекс цен квартала" not in (est.confidence_explanation or "")
+
+
+def test_target_quarter_from_own_coords_drives_correction() -> None:
+ """Квартал цели снят по ЕЁ координатам → поправка считается от него.
+
+ index цели 1.2 / avg аналогов 1.2 → factor 1.00: цена не двигается, потому что
+ уровень аналогов и уровень цели совпали. Старый код взял бы 0.8 у соседа и
+ ×0.67 без всяких данных о цели.
+ """
+ est = _run_estimate(
+ analogs=_analogs_two_quarters(),
+ dadata_cadnum=None,
+ coords_quarter=_TARGET_Q,
+ )
+
+ assert est.median_price_rub == round(_BASE_PPM2 * _AREA)
+ assert "индекс цен квартала ×1.00" in (est.confidence_explanation or "")
+
+
+def test_own_cadastre_wins_over_coords() -> None:
+ """Свой кадастр DaData приоритетнее координатного резолва — резолв не зовётся."""
+ analogs_cheap = [
+ _make_listing_qi(building_cadastral_number=f"{_NEIGH_CHEAP_Q}:{100 + i}", floor=2 + i)
+ for i in range(5)
+ ]
+ with patch("app.services.estimator._lookup_target_quarter_by_coords") as spy:
+ est = _run_estimate(analogs=analogs_cheap, dadata_cadnum=f"{_TARGET_Q}:350")
+
+ assert spy.call_count == 0
+ # target 1.2 / avg 0.8 = 1.5
+ assert est.median_price_rub == round(_BASE_PPM2 * _AREA * 1.5)
+
+
+def test_coords_resolver_dormant_by_default() -> None:
+ """Флаг выключен (прод-дефолт) → координатный резолв не зовётся вообще.
+
+ Поставляемое поведение = снятие фолбэка на кадастр аналога, и только оно:
+ точность самого резолва измерена (92% на 25 м), а эффект поправки на ЦЕНУ —
+ нет, поэтому включение — отдельное решение (см. config, критерий и дата).
+ """
+ with patch("app.services.estimator._lookup_target_quarter_by_coords") as spy:
+ est = _run_estimate(
+ analogs=_analogs_two_quarters(),
+ dadata_cadnum=None,
+ coords_quarter=_UNPATCHED,
+ coords_enabled=False,
+ )
+
+ assert spy.call_count == 0
+ assert est.median_price_rub == round(_BASE_PPM2 * _AREA)
+ assert "индекс цен квартала" not in (est.confidence_explanation or "")
diff --git a/tradein-mvp/backend/tests/test_estimator_yandex_integration.py b/tradein-mvp/backend/tests/test_estimator_yandex_integration.py
index b5d6ebbe..5962af60 100644
--- a/tradein-mvp/backend/tests/test_estimator_yandex_integration.py
+++ b/tradein-mvp/backend/tests/test_estimator_yandex_integration.py
@@ -178,21 +178,22 @@ def test_save_history_items_inserts_each():
):
saved = _save_yandex_history_items(db, result)
assert saved == 2
- # 1 batch INSERT (executemany). #2674 добавил вторым вызовом UPDATE
- # houses.has_panorama — считаем именно вставки истории, а не все execute.
+ # 1 batch INSERT (executemany). Фильтруем по SQL, а не по позиции вызова:
+ # #2674 однажды уже сдвинул позицию, добавив второй execute перед вставкой.
rows = _history_rows(db)
assert isinstance(rows, list) and len(rows) == 2
- # Два коммита: панорама (до истории) + батч истории. Раньше был один.
- assert db.commit.call_count == 2
+ # Один коммит — батч истории. Второй (UPDATE houses.has_panorama) ушёл вместе
+ # с колонкой, хвост #2674, мигр. 259.
+ assert db.commit.call_count == 1
def test_save_history_items_empty_no_commit():
"""Пустая история + НЕподтверждённая страница → дом резолвится, но не пишется ничего.
#2674 (ревью): ранний возврат по пустой истории раньше стоял ПЕРВЫМ и заодно
- отрезал запись houses.has_panorama для отрисованных страниц без объявлений (~10%).
- Теперь резолв дома идёт до возврата, поэтому match_or_create_house вызывается —
- а вот записей по-прежнему ноль: мета пустая, гейт панорамы не пропускает.
+ отрезал резолв дома для отрисованных страниц без объявлений (~10%). Теперь
+ match_or_create_house вызывается до возврата — а записей по-прежнему ноль:
+ истории нет, вставлять нечего.
"""
db = MagicMock()
result = YandexValuationResult(
@@ -246,9 +247,9 @@ def test_save_history_items_ext_id_stable_across_calls():
def test_save_history_items_db_error_rolls_back_batch():
"""Any item failing rolls back the whole batch — batch semantics (finding #5).
- #2674: side_effect адресуем по SQL, а не по позиции вызова — иначе исключение
- доставалось бы UPDATE houses.has_panorama (он идёт первым и свои ошибки глотает),
- а батч истории проходил бы успешно, и тест молча проверял бы не тот путь.
+ #2674: side_effect адресуем по SQL, а не по позиции вызова. Урок остаётся в силе
+ и после сноса has_panorama (мигр. 259): позиционный side_effect молча проверял бы
+ не тот путь, стоит появиться любому новому execute перед вставкой истории.
"""
db = MagicMock()
@@ -266,5 +267,4 @@ def test_save_history_items_db_error_rolls_back_batch():
saved = _save_yandex_history_items(db, result)
assert saved == 0 # whole batch rolled back
db.rollback.assert_called_once()
- # Панорама коммитится отдельно и раньше — её успех не отменяет отката истории.
- assert db.commit.call_count == 1
+ assert db.commit.call_count == 0
diff --git a/tradein-mvp/backend/tests/test_geocoder_city_substitution.py b/tradein-mvp/backend/tests/test_geocoder_city_substitution.py
new file mode 100644
index 00000000..b6626d94
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_geocoder_city_substitution.py
@@ -0,0 +1,277 @@
+"""Тесты #2590 — постфактум-инвариант «назван чужой топоним + результат внутри ЕКБ».
+
+Гейты #2582/#2589 стоят НА ВХОДЕ и решают, пускать ли ЕКБ-only локальные реестры.
+Внешние провайдеры ими не покрыты: «реж, ленина» уходит в Nominatim/Yandex, тот не
+находит Режа и отдаёт улицу Ленина в Екатеринбурге — с координатами ЕКБ, пометкой
+«точно» и записью в кэш на 90 дней. Замер на проде (`geocode_cache`, 9514 строк,
+2026-08-12): **1251 запись** подпадает под инвариант — 1245 `provider=yandex`,
+6 `nominatim`, 0 у локальных тиров; 365 разных населённых пунктов.
+
+Инвариант проверяется ПОСЛЕ ответа провайдера, потому что на входе «Реж» (город)
+и «Малышева» (улица) структурно неразличимы — оба «слово до запятой». После
+ответа различимы: провайдер сам пишет, какой НП он использовал.
+
+Адреса и full_address в тестах — живые строки из прод-кэша (не выдуманные).
+"""
+
+from __future__ import annotations
+
+import contextlib
+import os
+from unittest.mock import AsyncMock, MagicMock, patch
+
+os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db")
+
+import pytest
+
+from app.services.geocoder import (
+ GeocodeResult,
+ GeocodeSuggestion,
+ _city_substituted,
+ _claimed_locality,
+ geocode,
+)
+
+# ── _claimed_locality: какой НП назван в тексте ──────────────────────────────
+
+
+@pytest.mark.parametrize(
+ "address,expected",
+ [
+ # НП назван явно
+ ("реж, ленина", "реж"),
+ ("арамиль, красноармейская ул, 118дк1", "арамиль"),
+ ("пос. кедровка, советская ул.,5", "кедровка"), # тип «пос.» отброшен, имя осталось
+ # Уровни выше НП пропускаются, НП находится дальше
+ ("свердловская обл., г.о. рефтинский", "рефтинский"),
+ ("россия, свердловская область, реж, ленина 5", "реж"),
+ # НП НЕ назван — основной трафик формы оценки, инвариант не применяется
+ ("малышева 30", None),
+ ("ул. титова/монтерская/смоленская, стр. 2", None),
+ ("улица декабристов, 16-18литж · р-н октябрьский", None),
+ ("мкр-н широкая речка, ул. анатолия муранова,18", None),
+ # Перекрёсток: второй уличный сегмент НЕ должен читаться как топоним
+ ("малышева, мопра", "малышева"),
+ ],
+)
+def test_claimed_locality(address: str, expected: str | None) -> None:
+ assert _claimed_locality(address) == expected
+
+
+# ── _city_substituted: подмена vs честный ответ ──────────────────────────────
+
+
+def _res(full_address: str, lat: float, lon: float) -> GeocodeResult:
+ return GeocodeResult(
+ lat=lat, lon=lon, full_address=full_address, provider="nominatim", confidence="exact"
+ )
+
+
+@pytest.mark.parametrize(
+ "address,full_address,lat,lon",
+ [
+ # Живые записи прод-кэша из тела #2590 (все provider=yandex)
+ (
+ "реж, ленина",
+ "Россия, Свердловская область, Екатеринбург, улица Ленина",
+ 56.83953,
+ 60.61244,
+ ),
+ (
+ "реж, краснофлотцев",
+ "Россия, Свердловская область, Екатеринбург, улица Краснофлотцев",
+ 56.88996,
+ 60.63184,
+ ),
+ (
+ "арамиль, заводская",
+ "Россия, Свердловская область, Екатеринбург, Заводская улица",
+ 56.83043,
+ 60.55170,
+ ),
+ (
+ "свердловская обл., г.о. рефтинский",
+ "Россия, Свердловская область, Екатеринбург",
+ 56.83744,
+ 60.59764,
+ ),
+ # Города вне списка 37 и без структурной приставки — дыра, оставленная #2589
+ (
+ "талица, кузнецова",
+ "Россия, Свердловская область, Екатеринбург, улица Кузнецова",
+ 56.84226,
+ 60.64557,
+ ),
+ (
+ "верхотурье, герцена",
+ "Россия, Свердловская область, Екатеринбург, улица Герцена",
+ 56.744397,
+ 60.677299,
+ ),
+ # Посёлок В ЧЕРТЕ ЕКБ, но улица подменена: настоящая Кедровка в 20 км от
+ # улицы Советской, куда провайдер поставил точку (живая запись прода).
+ (
+ "пос. кедровка, советская ул.,5",
+ "Россия, Свердловская область, Екатеринбург, Советская улица, 5",
+ 56.8506,
+ 60.6282,
+ ),
+ ],
+)
+def test_substitution_detected(address: str, full_address: str, lat: float, lon: float) -> None:
+ assert _city_substituted(address, _res(full_address, lat, lon)) is True
+
+
+@pytest.mark.parametrize(
+ "address,full_address,lat,lon",
+ [
+ # Посёлок в черте ЕКБ, ответ ЧЕСТНЫЙ — имя дожило до full_address.
+ # Тот же вход, что и в подмене выше: разделяет их не география, а
+ # сохранность топонима (граница из #2777 — посёлки не ловятся
+ # «городским» признаком, и здесь ловить их и не нужно).
+ (
+ "пос. кедровка, советская ул.,5",
+ "5, Советская улица, Кедровка, Орджоникидзевский район, Екатеринбург",
+ 56.9902,
+ 60.7166,
+ ),
+ (
+ "шувакиш, ленина 4",
+ "4, улица Ленина, Шувакиш, Железнодорожный район, Екатеринбург",
+ 56.9295,
+ 60.4938,
+ ),
+ # НП не назван вообще — основной трафик формы, ЕКБ как рабочая гипотеза
+ (
+ "малышева 30",
+ "Россия, Свердловская область, Екатеринбург, улица Малышева, 30",
+ 56.8389,
+ 60.6057,
+ ),
+ # Явный Екатеринбург в адресе — не подмена по определению
+ ("екатеринбург, ул. репина, 99", "Репина, 99", 56.8207, 60.5686),
+ ("г екатеринбург, ул сурикова, д 47", "Сурикова, 47", 56.8156, 60.5943),
+ # Честный ответ по другому городу области: провайдер назвал НП, координаты
+ # в его собственном bbox — под инвариант не подпадает
+ ("нижний тагил, ленина 1", "1, проспект Ленина, Нижний Тагил", 57.9101, 59.9813),
+ # Ответ внутри ЕКБ-bbox, но провайдер назвал ДРУГОЙ НП (Берёзовский лежит
+ # внутри щедрого EKB_BBOX_TIGHT) — «внутри Екатеринбурга» не подтверждено
+ (
+ "режевской, ленина",
+ "Россия, Свердловская область, Берёзовский, улица Ленина",
+ 56.9088,
+ 60.8107,
+ ),
+ # Перекрёсток двух улиц: провайдер взял первую — потеря второй улицы не
+ # является подменой города
+ (
+ "малышева, мопра",
+ "Россия, Свердловская область, Екатеринбург, улица Малышева",
+ 56.8389,
+ 60.6057,
+ ),
+ # Тот же НП, но за пределами ЕКБ — инвариант молчит
+ ("реж, ленина", "Россия, Свердловская область, Реж, улица Ленина", 57.3722, 61.3897),
+ ],
+)
+def test_no_substitution(address: str, full_address: str, lat: float, lon: float) -> None:
+ assert _city_substituted(address, _res(full_address, lat, lon)) is False
+
+
+# ── geocode(): сквозной эффект ───────────────────────────────────────────────
+
+
+@contextlib.contextmanager
+def _tiers(*, cached=None, nominatim=None, geoportal=None):
+ with contextlib.ExitStack() as stack:
+ stack.enter_context(patch("app.services.geocoder._cache_get", return_value=cached))
+ stack.enter_context(patch("app.services.geocoder._cache_put"))
+ stack.enter_context(
+ patch("app.services.geocoder._geoportal_house_match", return_value=geoportal)
+ )
+ stack.enter_context(
+ patch("app.services.geocoder._cadastral_house_match", return_value=None)
+ )
+ stack.enter_context(patch("app.services.geocoder._cadastral_forward_sync", return_value=[]))
+ stack.enter_context(
+ patch("app.services.geocoder._nominatim_lookup", new=AsyncMock(return_value=nominatim))
+ )
+ stack.enter_context(patch("app.services.geocoder.asyncio.sleep", new=AsyncMock()))
+ yield
+
+
+async def test_geocode_downgrades_substituted_result() -> None:
+ """«Реж, Ленина 5» → провайдер отдал улицу Ленина в ЕКБ с confidence=exact.
+
+ Координаты остаются (честная деградация, не отказ), но confidence падает до
+ `locality` — а это уже рабочий контракт: `estimator._geocode_is_coarse`
+ включает #693 coarse-downgrade, `tasks.geocode_missing` ставит листингу
+ `geo_precision='city'`, что исключает его из пула аналогов.
+ """
+ db = MagicMock()
+ substituted = GeocodeResult(
+ lat=56.83953,
+ lon=60.61244,
+ full_address="Россия, Свердловская область, Екатеринбург, улица Ленина, 5",
+ provider="nominatim",
+ confidence="exact",
+ )
+ with _tiers(nominatim=substituted):
+ result = await geocode("Реж, Ленина 5", db)
+
+ assert result is not None
+ assert result.confidence == "locality"
+ assert result.city_ambiguous is True
+ assert result.lat == pytest.approx(56.83953) # координаты НЕ выброшены
+
+
+async def test_geocode_poisoned_cache_hit_downgraded() -> None:
+ """Отравленная запись кэша (их 1251 на проде) продолжает отдаваться, но уже
+ НЕ как точная — без единого DELETE в `geocode_cache`."""
+ db = MagicMock()
+ poisoned = GeocodeResult(
+ lat=56.83744,
+ lon=60.59764,
+ full_address="Россия, Свердловская область, Екатеринбург",
+ provider="cache",
+ confidence="exact",
+ )
+ with _tiers(cached=poisoned):
+ result = await geocode("Арамиль, Красноармейская ул, 118дк1", db)
+
+ assert result is not None
+ assert result.confidence == "locality"
+ assert result.city_ambiguous is True
+
+
+async def test_geocode_ekb_address_unaffected() -> None:
+ """Контроль: реальный ЕКБ-адрес через локальный тир (full_address без города)
+ остаётся `exact` — инвариант не трогает основной трафик."""
+ db = MagicMock()
+ hit = GeocodeSuggestion(
+ label="Малышева, 30", full_address="Малышева, 30", lat=56.8389, lon=60.6057, kind="house"
+ )
+ with _tiers(geoportal=hit):
+ result = await geocode("Екатеринбург, Малышева 30", db)
+
+ assert result is not None
+ assert result.confidence == "exact"
+ assert result.city_ambiguous is False
+
+
+async def test_geocode_settlement_inside_ekb_kept_exact() -> None:
+ """Посёлок в городской черте (Кедровка) с ЧЕСТНЫМ ответом провайдера не
+ отбраковывается — граница, о которой предупреждает #2777."""
+ db = MagicMock()
+ honest = GeocodeResult(
+ lat=56.9902,
+ lon=60.7166,
+ full_address="5, Советская улица, Кедровка, Орджоникидзевский район, Екатеринбург",
+ provider="nominatim",
+ confidence="exact",
+ )
+ with _tiers(nominatim=honest):
+ result = await geocode("пос. Кедровка, Советская ул., 5", db)
+
+ assert result is not None
+ assert result.confidence == "exact"
diff --git a/tradein-mvp/backend/tests/test_house_imv_params_honesty.py b/tradein-mvp/backend/tests/test_house_imv_params_honesty.py
index c36aa891..98f057aa 100644
--- a/tradein-mvp/backend/tests/test_house_imv_params_honesty.py
+++ b/tradein-mvp/backend/tests/test_house_imv_params_honesty.py
@@ -138,7 +138,11 @@ async def test_unknown_house_type_skips_request_and_marks_house() -> None:
patch.object(hib, "evaluate_via_imv", new_callable=AsyncMock) as mock_eval,
patch.object(hib, "_mark_status") as mock_mark,
):
- db.execute.return_value.mappings.return_value.all.return_value = houses
+ # #2674: backfill делает ДВЕ выборки — сначала retry-очередь transient_error,
+ # затем основную по only_status. Фейк отвечает одним и тем же списком на любой
+ # SELECT, поэтому очередь повтора отдаём пустой явно — иначе один и тот же дом
+ # придёт в пакет дважды (артефакт фейка, в БД статусы не пересекаются).
+ db.execute.return_value.mappings.return_value.all.side_effect = [[], houses]
result = await hib.backfill_house_imv(db, batch_size=10, request_delay_sec=0.0)
mock_eval.assert_not_called()
diff --git a/tradein-mvp/backend/tests/test_house_imv_retry_stuck.py b/tradein-mvp/backend/tests/test_house_imv_retry_stuck.py
new file mode 100644
index 00000000..b414aefc
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_house_imv_retry_stuck.py
@@ -0,0 +1,267 @@
+"""#2674: «временная» ошибка домовой IMV-оценки снова означает временную.
+
+Что было. Очередь backfill'а выбирает РОВНО ОДИН imv_status за прогон
+(`only_status`, дефолт 'pending'). Возможность повтора существовала и была
+задокументирована в admin-API, но за всю историю (41 прогон, 26.06–11.08.2026)
+её не передал никто: все прогоны ушли с 'pending'. Дома, упавшие по причине,
+которой больше нет (503/500 от браузерного сайдкара, #2698), лежали в
+'transient_error' без единого шанса вернуться — 1390 штук на 12.08.2026.
+
+Что проверяем — по существу, а не по настройке:
+ * дом в 'transient_error' ПОПАДАЕТ в пакет прогона с дефолтными параметрами
+ (тот же вызов, что делает расписание) — на старом коде он не попадает;
+ * pending при этом из пакета НЕ вытесняется;
+ * дом, исчерпавший лимит попыток, в автоматический повтор не берётся, а
+ ЯВНЫЙ only_status='transient_error' (ручной запрос оператора) берёт и его;
+ * дом без пригодных параметров помечается no_params БЕЗ запроса к площадке.
+
+Живой Postgres обязателен: проверяется поведение SQL-выборки, а не то, какие
+строки вернул мок. Без БД тест самоскипается (в CI БД есть — ci-tradein.yml
+поднимает Postgres и накатывает схему).
+"""
+
+from __future__ import annotations
+
+import os
+from typing import Any
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
+
+from app.services import house_imv_backfill as hib
+
+# Фикстуры живут в собственном диапазоне id (ср. tests/test_house_dedup_merge.py).
+_H_STUCK = 900601 # transient_error, попыток 0 → обязан вернуться в очередь
+_H_EXHAUSTED = 900602 # transient_error, попытки исчерпаны → в автоповтор не берём
+_H_PENDING = 900603 # pending → не должен быть вытеснен retry-очередью
+_H_NO_PARAMS = 900604 # pending без объявлений с rooms+area → no_params без запроса
+_IDS = (_H_STUCK, _H_EXHAUSTED, _H_PENDING, _H_NO_PARAMS)
+
+
+def _live_session() -> Any | None:
+ """Session к достижимому не-заглушечному Postgres, иначе None."""
+ try:
+ from sqlalchemy import create_engine, text
+ from sqlalchemy.orm import sessionmaker
+
+ dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
+ if not dsn or "localhost:5432/test" in dsn:
+ return None
+ engine = create_engine(dsn, future=True)
+ conn = engine.connect()
+ conn.execute(text("SELECT 1"))
+ conn.close()
+ return sessionmaker(bind=engine, future=True)()
+ except Exception:
+ return None
+
+
+def _seed(db: Any) -> None:
+ from sqlalchemy import text as _t
+
+ # url/source/ext_house_id — NOT NULL в houses (002_core_tables).
+ db.execute(
+ _t(
+ "INSERT INTO houses (id, source, ext_house_id, url, address, lat, lon, "
+ " imv_status, imv_transient_attempts, last_imv_attempt_at) VALUES "
+ "(:s, 'avito', 'EXT-2674-S', 'u', 'тестдом 2674, 1', 56.84, 60.60, "
+ " 'transient_error', 0, NOW() - INTERVAL '10 days'),"
+ "(:x, 'avito', 'EXT-2674-X', 'u', 'тестдом 2674, 2', 56.84, 60.60, "
+ " 'transient_error', :cap, NOW() - INTERVAL '10 days'),"
+ "(:p, 'avito', 'EXT-2674-P', 'u', 'тестдом 2674, 3', 56.84, 60.60, "
+ " 'pending', 0, NULL),"
+ "(:n, 'avito', 'EXT-2674-N', 'u', 'тестдом 2674, 4', 56.84, 60.60, "
+ " 'pending', 0, NULL)"
+ ),
+ {
+ "s": _H_STUCK,
+ "x": _H_EXHAUSTED,
+ "p": _H_PENDING,
+ "n": _H_NO_PARAMS,
+ # Литерал, а не hib._MAX_TRANSIENT_ATTEMPTS: подготовка теста обязана
+ # работать и на СТАРОМ коде, иначе красный прогон получится про
+ # отсутствующий атрибут, а не про застрявший дом. Синхронность
+ # литерала и константы стережёт test_transient_attempts_counter_*.
+ "cap": 3,
+ },
+ )
+ # Объявления с rooms+area+house_type — у всех, КРОМЕ _H_NO_PARAMS.
+ # source_url / dedup_hash / price_rub — NOT NULL, dedup_hash UNIQUE.
+ db.execute(
+ _t(
+ "INSERT INTO listings (id, source, source_url, source_id, dedup_hash, price_rub, "
+ " house_id_fk, rooms, area_m2, floor, total_floors, house_type) "
+ "VALUES "
+ "(:l1, 'avito', 'http://t/2674/1', 'L-2674-1', 'dh-2674-1', 5000000, :s, "
+ " 2, 52.0, 3, 9, 'brick'),"
+ "(:l2, 'avito', 'http://t/2674/2', 'L-2674-2', 'dh-2674-2', 5000000, :x, "
+ " 2, 52.0, 3, 9, 'brick'),"
+ "(:l3, 'avito', 'http://t/2674/3', 'L-2674-3', 'dh-2674-3', 5000000, :p, "
+ " 2, 52.0, 3, 9, 'brick')"
+ ),
+ {
+ "l1": 910601,
+ "l2": 910602,
+ "l3": 910603,
+ "s": _H_STUCK,
+ "x": _H_EXHAUSTED,
+ "p": _H_PENDING,
+ },
+ )
+ db.commit()
+
+
+def _cleanup(db: Any) -> None:
+ from sqlalchemy import text as _t
+
+ db.rollback()
+ db.execute(_t("DELETE FROM listings WHERE id IN (910601, 910602, 910603)"))
+ db.execute(_t("DELETE FROM houses WHERE id = ANY(CAST(:ids AS bigint[]))"), {"ids": list(_IDS)})
+ db.commit()
+ db.close()
+
+
+async def _run(db: Any, **kwargs: Any) -> Any:
+ """Прогнать backfill с замоканным вызовом площадки.
+
+ Мокаем ровно сетевую границу: evaluate_via_imv поднимает «адрес не найден»,
+ поэтому дом уходит в not_found и путь сохранения (три таблицы) не нужен —
+ проверяем именно ВЫБОРКУ. Флаг браузерного фетчера гасим, чтобы не поднимать
+ сайдкар.
+ """
+ err = hib.IMVAddressNotFoundError("address not found (test)")
+ with (
+ patch.object(hib.settings, "avito_imv_use_browser_fetcher", False),
+ patch.object(hib, "evaluate_via_imv", AsyncMock(side_effect=err)),
+ ):
+ return await hib.backfill_house_imv(db, request_delay_sec=0.0, **kwargs)
+
+
+@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
+async def test_stuck_transient_house_returns_to_the_queue_by_itself() -> None:
+ """Прогон с дефолтными параметрами (как у расписания) забирает застрявший дом.
+
+ На старом коде выборка — `WHERE imv_status = :status` с единственным
+ значением 'pending', поэтому дом остаётся в 'transient_error' навсегда.
+ """
+ from sqlalchemy import text as _t
+
+ db = _live_session()
+ assert db is not None
+ if db.execute(_t("SELECT count(*) FROM houses")).scalar():
+ pytest.skip("тестовая БД не пуста — фикстуры не попадут в голову очереди")
+ try:
+ _seed(db)
+
+ # Ровно то, что вызывает расписание: batch_size из default_params, статус по умолчанию.
+ result = await _run(db, batch_size=50)
+
+ picked = {
+ r.id: r.imv_status
+ for r in db.execute(
+ _t("SELECT id, imv_status FROM houses WHERE id = ANY(CAST(:ids AS bigint[]))"),
+ {"ids": list(_IDS)},
+ )
+ }
+ assert picked[_H_STUCK] != "transient_error", (
+ "дом с временной ошибкой и непотраченным лимитом обязан попасть в пакет "
+ f"обычного прогона; фактически статус {picked[_H_STUCK]!r} "
+ f"(в пакет прогона попало домов: {result.checked})"
+ )
+ # pending не вытеснен повтором.
+ assert picked[_H_PENDING] != "pending", "pending-дом обязан остаться в том же пакете"
+ # Исчерпавший лимит в автоповтор не берётся — иначе вечный цикл.
+ assert picked[_H_EXHAUSTED] == "transient_error"
+ assert result.retried == 1
+ # Дом без объявлений с rooms+area помечен ДО пакета, слот не потрачен.
+ assert picked[_H_NO_PARAMS] == "no_params"
+ assert result.premarked == 1
+ finally:
+ _cleanup(db)
+
+
+@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
+async def test_explicit_only_status_still_takes_exhausted_houses() -> None:
+ """Явный only_status — ручной запрос оператора: лимит попыток его не режет."""
+ from sqlalchemy import text as _t
+
+ db = _live_session()
+ assert db is not None
+ if db.execute(_t("SELECT count(*) FROM houses")).scalar():
+ pytest.skip("тестовая БД не пуста — фикстуры не попадут в голову очереди")
+ try:
+ _seed(db)
+
+ result = await _run(db, batch_size=50, only_status="transient_error")
+
+ statuses = {
+ r.id: r.imv_status
+ for r in db.execute(
+ _t("SELECT id, imv_status FROM houses WHERE id = ANY(CAST(:ids AS bigint[]))"),
+ {"ids": list(_IDS)},
+ )
+ }
+ assert (
+ statuses[_H_EXHAUSTED] != "transient_error"
+ ), "явно запрошенный статус обрабатывается целиком, включая исчерпавшие лимит"
+ # Автоповтора поверх явного запроса нет: pending не тронут.
+ assert statuses[_H_PENDING] == "pending"
+ assert result.retried == 0
+ finally:
+ _cleanup(db)
+
+
+@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
+async def test_transient_attempts_counter_only_counts_transient() -> None:
+ """Счётчик — это «сколько раз подряд упал по временной причине», и он обнуляем.
+
+ Без верхней границы у повтора не было бы выхода; без обнуления успехом дом,
+ однажды поймавший 3 сетевых сбоя, навсегда терял бы право на повтор.
+ """
+ from sqlalchemy import text as _t
+
+ db = _live_session()
+ assert db is not None
+ if db.execute(_t("SELECT count(*) FROM houses")).scalar():
+ pytest.skip("тестовая БД не пуста — фикстуры не попадут в голову очереди")
+ try:
+ _seed(db)
+ assert hib._MAX_TRANSIENT_ATTEMPTS == 3, "литерал 'cap' в _seed рассинхронизирован"
+
+ def _attempts() -> int:
+ return int(
+ db.execute(
+ _t("SELECT imv_transient_attempts FROM houses WHERE id = :i"),
+ {"i": _H_STUCK},
+ ).scalar()
+ )
+
+ hib._mark_status(db, _H_STUCK, "transient_error", "boom")
+ assert _attempts() == 1
+ hib._mark_status(db, _H_STUCK, "not_found", "адрес не найден")
+ assert _attempts() == 1, "нетранзиентные исходы счётчик не двигают"
+ db.execute(
+ _t("UPDATE houses SET imv_status='ok', imv_transient_attempts=0 WHERE id = :i"),
+ {"i": _H_STUCK},
+ )
+ db.commit()
+ assert _attempts() == 0
+ finally:
+ _cleanup(db)
+
+
+def test_retry_queue_is_not_bound_to_only_status() -> None:
+ """Статика (бежит и без БД): retry-выборка не зависит от параметра only_status.
+
+ Ровно этот дефект и застрял на проде: единственный путь к 'transient_error'
+ шёл через значение, которое никто не передавал.
+ """
+ retry_sql = str(hib._RETRY_QUEUE_SQL.text)
+ assert "imv_status = 'transient_error'" in retry_sql
+ assert ":status" not in retry_sql, "retry-очередь обязана быть независимой от only_status"
+ assert "imv_transient_attempts < :max_attempts" in retry_sql, "у повтора должен быть выход"
+ # psycopg v3: только CAST(:x AS type), никогда :x::type.
+ for sql in (retry_sql, str(hib._QUEUE_SQL.text), str(hib._PREMARK_UNUSABLE_SQL.text)):
+ assert "::" not in sql
diff --git a/tradein-mvp/backend/tests/test_migration_257_yandex_source_url_backfill.py b/tradein-mvp/backend/tests/test_migration_257_yandex_source_url_backfill.py
new file mode 100644
index 00000000..6a8ce9b3
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_migration_257_yandex_source_url_backfill.py
@@ -0,0 +1,79 @@
+"""Static guards for migration 257 (#2838): лечение yandex source_url.
+
+Живой БД у юнит-теста нет, поэтому проверяем то, что ломается тихо и навсегда:
+
+ 1. Форма адреса не разъехалась с продюсером. В файле стоит ДОСЛОВНО
+ `CANONICAL_URL_SQL` из app/tasks/yandex_detail_backfill.py, а тот уже сверен с
+ `_canonical_source_url` продюсера в test_yandex_detail_backfill.py. Сцепление
+ транзитивное: поменяют форму у продюсера — красным станет тот тест, поменяют в
+ миграции — этот.
+ 2. Отбор — теми же константами, что у очереди (#2838), а не «похожими» regexp'ами.
+ 3. Бэкап прежних значений пишется ДО UPDATE. Переставь местами — таблица отката
+ наполнится уже канонизированными адресами, и откат станет no-op'ом, который
+ выглядит рабочим.
+ 4. Недеструктивность и транзакционность: одна колонка, BEGIN/COMMIT, lock_timeout.
+"""
+
+from __future__ import annotations
+
+import re
+from pathlib import Path
+
+from app.tasks.yandex_detail_backfill import (
+ CANONICAL_URL_SQL,
+ OFFER_ID_PATTERN,
+ OFFER_URL_PATTERN,
+)
+
+_MIGRATION = (
+ Path(__file__).resolve().parents[1]
+ / "data"
+ / "sql"
+ / "257_listings_backfill_yandex_source_url.sql"
+)
+
+
+def _executable_sql() -> str:
+ """SQL без `--`-комментариев: шапка объясняет прозой и упоминает всё подряд."""
+ lines = [raw.split("--", 1)[0] for raw in _MIGRATION.read_text(encoding="utf-8").splitlines()]
+ return "\n".join(line for line in lines if line.strip())
+
+
+def _flat(text: str) -> str:
+ return re.sub(r"\s+", " ", text).strip()
+
+
+def test_migration_exists() -> None:
+ assert _MIGRATION.exists(), f"missing migration: {_MIGRATION}"
+
+
+def test_canonical_url_matches_producer_formula() -> None:
+ """Выражение UPDATE — тот же литерал, что CANONICAL_URL_SQL (и, значит, продюсер)."""
+ assert _flat(CANONICAL_URL_SQL) in _flat(_executable_sql())
+
+
+def test_selects_rows_by_the_same_constants_as_the_queue() -> None:
+ """Отбор — константами очереди #2838, иначе миграция чинит не то множество."""
+ sql = _executable_sql()
+ assert f"'{OFFER_URL_PATTERN}'" in sql
+ assert f"'{OFFER_ID_PATTERN}'" in sql
+ assert "source = 'yandex'" in sql
+
+
+def test_backup_is_written_before_the_update() -> None:
+ sql = _executable_sql()
+ assert sql.index("INSERT INTO yandex_source_url_backfill_257") < sql.index("UPDATE listings")
+
+
+def test_transactional_and_lock_timeout_after_begin() -> None:
+ sql = _executable_sql()
+ assert "BEGIN;" in sql and "COMMIT;" in sql
+ assert sql.index("BEGIN;") < sql.index("SET LOCAL lock_timeout")
+ assert sql.index("SET LOCAL lock_timeout") < sql.index("UPDATE listings")
+
+
+def test_touches_only_source_url_and_deletes_nothing() -> None:
+ sql = _executable_sql().upper()
+ for forbidden in ("DELETE", "DROP", "TRUNCATE", "IS_ACTIVE"):
+ assert forbidden not in sql, forbidden
+ assert sql.count("SET SOURCE_URL") == 1
diff --git a/tradein-mvp/backend/tests/test_sber_freshness_monitor.py b/tradein-mvp/backend/tests/test_sber_freshness_monitor.py
index 726d991e..fafe6f41 100644
--- a/tradein-mvp/backend/tests/test_sber_freshness_monitor.py
+++ b/tradein-mvp/backend/tests/test_sber_freshness_monitor.py
@@ -1,13 +1,20 @@
-"""Freshness-монитор данных СберИндекса по max(period_month) — audit п.1.
+"""Монитор СберИндекса: тревога про ОТСТАВАНИЕ ЗАГРУЗКИ, а не про календарь (#2846).
Покрывает:
- 1. Чистую логику evaluate_sber_freshness (frozen now, без БД):
- - fresh: age <= max_age_days (алерта нет);
- - stale: age > max_age_days (алерт);
- - граница порога (== max_age_days → нет алерта; +1 день → алерт).
- 2. check_sber_freshness с FakeDB (fresh / stale / empty→mark_failed / кастомный lag).
- 3. Свойства миграции 180 (по образцу test_deals_freshness_monitor).
- 4. Регистрацию в kit product_handlers (registry остаётся зелёным).
+ 1. Чистую логику evaluate_sber_freshness (frozen now, без БД): загрузка в такте /
+ загрузка встала / полных прогонов не было вовсе / граница порога.
+ 2. check_sber_freshness с FakeDB — прод-реплей 2026-08-12 и двусторонность:
+ при ОДНОМ И ТОМ ЖЕ возрасте периода вердикт меняется вслед за загрузкой.
+ 3. Выбор табло тем же порядком, что у оценщика (max() по таблице маскировал бы
+ отставшее табло).
+ 4. Свойства миграций 180/212 + регистрацию в kit product_handlers.
+
+Прод-числа (read-only, 2026-08-12, scrape_runs/scrape_schedules/sber_price_index):
+ latest период табло оценщика real_estate_deals = 2026-06-01 (возраст 72 суток),
+ dinamika-tsen-obyavlenii = 2026-05-01 (103);
+ последний ПОЛНЫЙ прогон загрузки = 2026-08-06 (errors=0, upserted=639);
+ такт загрузки interval_days = 7;
+ прогон id=37 (05-31) — status='done' при {errors: 9, upserted: 0}, за успех НЕ считается.
"""
from __future__ import annotations
@@ -22,7 +29,6 @@ import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
-from app.core.config import settings
from app.services.product_handlers import build_product_handlers
from app.tasks import sber_freshness_monitor as mon
@@ -30,69 +36,152 @@ _SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql"
_MIGRATION_180 = _SQL_DIR / "180_seed_sber_freshness_monitor.sql"
_MIGRATION_212 = _SQL_DIR / "212_sber_index_pull_weekly.sql"
-# max(period_month) вторичного сегмента = 2026-05-01 (проверено на проде 2026-07-12).
-_MAY_2026 = date(2026, 5, 1)
-
-
-# ── evaluate_sber_freshness (чистая логика, frozen now) ───────────────────────
+# Прод-состояние 2026-08-12.
+_JUN_2026 = date(2026, 6, 1) # latest табло real_estate_deals — возраст 72 суток
+_MAY_2026 = date(2026, 5, 1) # latest табло dinamika-tsen-obyavlenii — возраст 103
+_LAST_FULL_PULL = datetime(2026, 8, 6, 5, 0, tzinfo=UTC) # errors=0, upserted=639
+_PULL_INTERVAL = 7 # scrape_schedules.default_params.interval_days
+_PROD_NOW = datetime(2026, 8, 12, 19, 6, tzinfo=UTC) # момент прод-замера
def _now(y: int, m: int, d: int) -> datetime:
return datetime(y, m, d, tzinfo=UTC)
-def test_fresh_within_max_age() -> None:
- """age=30 ≤ max_age_days=60 — свежий, алерта нет."""
- v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 5, 31), max_age_days=60)
- assert v.stale is False
- assert v.age_days == 30
- assert v.latest_period == _MAY_2026
+# ── evaluate_sber_freshness (чистая логика, frozen now) ───────────────────────
-def test_stale_beyond_max_age() -> None:
- """Прод-состояние: 2026-05-01 @ 2026-07-12 — age=72 > 60 → алерт."""
- v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 7, 12), max_age_days=60)
- assert v.stale is True
+def test_loader_in_cadence_no_alert_even_at_age_72() -> None:
+ """Прод 2026-08-12: возраст 72, но полный прогон 6 суток назад → молчим.
+
+ После полного прогона наш max(period_month) равен максимуму источника ПО
+ ПОСТРОЕНИЮ (загрузчик тянет всю серию), значит 72 суток — лаг ПУБЛИКАЦИИ Сбера,
+ а не наше отставание.
+ """
+ v = mon.evaluate_sber_freshness(
+ _JUN_2026,
+ _PROD_NOW,
+ last_complete_pull_at=_LAST_FULL_PULL,
+ pull_interval_days=_PULL_INTERVAL,
+ )
assert v.age_days == 72
+ assert v.pull_lag_days == 6
+ assert v.stale is False
+
+
+def test_loader_stalled_alerts_at_the_same_age() -> None:
+ """Тот же возраст периода, но полный прогон 20 суток назад → тревога.
+
+ 20 суток — реальный разрыв прод-истории (07-17 → 08-06) при пороге 2×7=14.
+ """
+ v = mon.evaluate_sber_freshness(
+ _JUN_2026,
+ _PROD_NOW,
+ last_complete_pull_at=_now(2026, 7, 23),
+ pull_interval_days=_PULL_INTERVAL,
+ )
+ assert v.age_days == 72 # возраст ТОТ ЖЕ, что в тесте выше
+ assert v.pull_lag_days == 20
+ assert v.max_pull_lag_days == 14
+ assert v.stale is True
+
+
+def test_no_complete_pull_ever_alerts() -> None:
+ """Загрузчик умер совсем / не отработал ни разу успешно → тревога, не тишина."""
+ v = mon.evaluate_sber_freshness(
+ _JUN_2026,
+ _PROD_NOW,
+ last_complete_pull_at=None,
+ pull_interval_days=_PULL_INTERVAL,
+ )
+ assert v.stale is True
+ assert v.pull_lag_days == -1
def test_threshold_boundary_exact_no_alert() -> None:
- """Ровно на пороге (age == max_age_days) алерта ещё нет (строгое >)."""
- v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 6, 30), max_age_days=60)
- assert v.age_days == 60
+ """Ровно на пороге (2 такта) алерта ещё нет — строгое >."""
+ v = mon.evaluate_sber_freshness(
+ _JUN_2026,
+ _LAST_FULL_PULL + timedelta(days=mon.MISSED_PULL_CYCLES * _PULL_INTERVAL),
+ last_complete_pull_at=_LAST_FULL_PULL,
+ pull_interval_days=_PULL_INTERVAL,
+ )
+ assert v.pull_lag_days == 14
assert v.stale is False
-def test_threshold_boundary_next_day_alert() -> None:
- """Порог + 1 день (age=61) — первый алерт."""
- v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 7, 1), max_age_days=60)
- assert v.age_days == 61
+def test_threshold_boundary_next_day_alerts() -> None:
+ """Порог + 1 сутки — первый алерт (два такта подряд пропущены)."""
+ v = mon.evaluate_sber_freshness(
+ _JUN_2026,
+ _LAST_FULL_PULL + timedelta(days=mon.MISSED_PULL_CYCLES * _PULL_INTERVAL + 1),
+ last_complete_pull_at=_LAST_FULL_PULL,
+ pull_interval_days=_PULL_INTERVAL,
+ )
+ assert v.pull_lag_days == 15
assert v.stale is True
+def test_threshold_follows_pull_cadence() -> None:
+ """Порог — производная такта загрузки, а не константа: такт 28 → порог 56."""
+ v = mon.evaluate_sber_freshness(
+ _JUN_2026,
+ _PROD_NOW,
+ last_complete_pull_at=_now(2026, 7, 23), # 20 суток
+ pull_interval_days=28,
+ )
+ assert v.max_pull_lag_days == 56
+ assert v.stale is False # при месячном такте 20 суток — норма
+
+
# ── check_sber_freshness (FakeDB) ─────────────────────────────────────────────
class _Row:
- def __init__(self, latest: date | None) -> None:
- self.latest = latest
+ def __init__(self, **kw: Any) -> None:
+ self.__dict__.update(kw)
class _FakeResult:
- def __init__(self, latest: date | None) -> None:
- self._latest = latest
+ def __init__(self, row: _Row | None) -> None:
+ self._row = row
- def first(self) -> _Row:
- return _Row(self._latest)
+ def first(self) -> _Row | None:
+ return self._row
class _FakeDB:
- def __init__(self, latest: date | None) -> None:
- self._latest = latest
+ """Отвечает на три запроса монитора; пригоден и для старой версии кода.
+
+ Старый монитор спрашивал max(period_month) БЕЗ dashboard-фильтра — на такой
+ запрос отдаём максимум по всем табло, ровно как это делал бы Postgres.
+ """
+
+ def __init__(
+ self,
+ latest_by_dash: dict[str, date],
+ last_pull: datetime | None = _LAST_FULL_PULL,
+ interval_days: str | None = str(_PULL_INTERVAL),
+ ) -> None:
+ self._latest_by_dash = latest_by_dash
+ self._last_pull = last_pull
+ self._interval_days = interval_days
self.rolled_back = False
+ self.asked_dashboards: list[str] = []
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult:
- return _FakeResult(self._latest)
+ sql = str(stmt)
+ params = params or {}
+ if "scrape_runs" in sql:
+ return _FakeResult(_Row(last_pull=self._last_pull))
+ if "scrape_schedules" in sql:
+ return _FakeResult(_Row(interval_days=self._interval_days))
+ if "dash" in params:
+ self.asked_dashboards.append(params["dash"])
+ return _FakeResult(_Row(latest=self._latest_by_dash.get(params["dash"])))
+ # Старый монитор: max(period_month) по всей таблице.
+ latest = max(self._latest_by_dash.values()) if self._latest_by_dash else None
+ return _FakeResult(_Row(latest=latest))
def rollback(self) -> None:
self.rolled_back = True
@@ -118,60 +207,138 @@ def _patch_runs(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
return calls
-def test_check_fresh_marks_done(monkeypatch: pytest.MonkeyPatch) -> None:
- calls = _patch_runs(monkeypatch)
- db = _FakeDB(_MAY_2026)
- # @2026-05-31: age=30 ≤ 35+25=60 → нет алерта.
- out = mon.check_sber_freshness(db, run_id=1, params={}, now=_now(2026, 5, 31)) # type: ignore[arg-type]
- assert out == {"latest_year": 2026, "latest_month": 5, "age_days": 30, "alert": 0}
- assert calls["done"] == out
- assert calls["failed"] is None
+def _prod_db(last_pull: datetime | None = _LAST_FULL_PULL) -> _FakeDB:
+ """Прод-состояние 2026-08-12 (оба табло вторички)."""
+ return _FakeDB(
+ {"real_estate_deals": _JUN_2026, "dinamika-tsen-obyavlenii": _MAY_2026},
+ last_pull=last_pull,
+ )
-def test_check_stale_marks_done_with_alert(monkeypatch: pytest.MonkeyPatch) -> None:
+def test_prod_replay_healthy_loader_silent_source_no_alert(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """КРАСНЫЙ НА main. Прод 2026-08-12: загрузка исправна, источник молчит → тишина.
+
+ На main монитор мерил календарь (72 > 60) и писал ERROR — двенадцатые сутки
+ подряд, при полном прогоне загрузки 08-06. Тревога описывала лаг публикации
+ Сбера, а не наш дефект, и на настоящий отказ загрузчика выглядела бы так же.
+ """
calls = _patch_runs(monkeypatch)
- db = _FakeDB(_MAY_2026)
- # @2026-07-12 (прод): age=72 > 60 → алерт.
- out = mon.check_sber_freshness(db, run_id=2, params={}, now=_now(2026, 7, 12)) # type: ignore[arg-type]
- assert out["alert"] == 1
+ db = _prod_db()
+ out = mon.check_sber_freshness(db, run_id=1, params={}, now=_PROD_NOW) # type: ignore[arg-type]
assert out["age_days"] == 72
- assert out["latest_month"] == 5
- # Монитор НЕ падает при алерте — прогон done, а не failed.
+ assert out["alert"] == 0
assert calls["done"] == out
assert calls["failed"] is None
-def test_check_empty_index_marks_failed(monkeypatch: pytest.MonkeyPatch) -> None:
+def test_alert_tracks_loader_not_calendar(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Двусторонность: возраст периода одинаков, вердикт идёт за загрузкой.
+
+ На main оба состояния дают alert=1 (вердикт зависит только от календаря) —
+ сторож не умеет зеленеть, что и было исходным дефектом.
+ """
+ _patch_runs(monkeypatch)
+ healthy = mon.check_sber_freshness(
+ _prod_db(last_pull=_LAST_FULL_PULL), # type: ignore[arg-type]
+ run_id=2,
+ params={},
+ now=_PROD_NOW,
+ )
+ stalled = mon.check_sber_freshness(
+ _prod_db(last_pull=_now(2026, 7, 23)), # 20 суток назад > 14 # type: ignore[arg-type]
+ run_id=3,
+ params={},
+ now=_PROD_NOW,
+ )
+ assert healthy["age_days"] == stalled["age_days"] == 72
+ assert (healthy["alert"], stalled["alert"]) == (0, 1)
+
+
+def test_dead_loader_never_pulled_marks_alert(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Загрузчик умер совсем (ни одного полного прогона) → монитор НЕ молчит.
+
+ Прогон id=37 со status='done' при {errors: 9, upserted: 0} за успех не идёт —
+ SQL требует errors=0 AND upserted>0, поэтому «полных прогонов не было» здесь
+ ровно то состояние, что дал бы прод с одним лишь id=37.
+ """
+ _patch_runs(monkeypatch)
+ out = mon.check_sber_freshness(
+ _prod_db(last_pull=None), # type: ignore[arg-type]
+ run_id=4,
+ params={},
+ now=_PROD_NOW,
+ )
+ assert out["alert"] == 1
+ assert out["pull_lag_days"] == -1
+
+
+def test_asks_estimator_dashboard_first(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Табло — то же и в том же порядке, что берёт оценщик (не max() по таблице)."""
+ _patch_runs(monkeypatch)
+ db = _prod_db()
+ out = mon.check_sber_freshness(db, run_id=5, params={}, now=_PROD_NOW) # type: ignore[arg-type]
+ assert db.asked_dashboards[0] == "real_estate_deals"
+ assert (out["latest_year"], out["latest_month"]) == (2026, 6)
+
+
+def test_falls_back_to_next_dashboard_when_first_empty(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Первое табло пусто → берём следующее, как и оценщик."""
+ _patch_runs(monkeypatch)
+ db = _FakeDB({"dinamika-tsen-obyavlenii": _MAY_2026})
+ out = mon.check_sber_freshness(db, run_id=6, params={}, now=_PROD_NOW) # type: ignore[arg-type]
+ assert out["latest_month"] == 5
+ assert out["age_days"] == 103
+
+
+def test_empty_index_marks_failed(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Ни одного табло с данными — оценивать нечего, это сбой монитора."""
calls = _patch_runs(monkeypatch)
- db = _FakeDB(None)
- out = mon.check_sber_freshness(db, run_id=3, params={}, now=_now(2026, 7, 12)) # type: ignore[arg-type]
+ out = mon.check_sber_freshness(_FakeDB({}), run_id=7, params={}, now=_PROD_NOW) # type: ignore[arg-type]
assert out["alert"] == 0
assert calls["done"] is None
assert calls["failed"] is not None
-def test_check_reads_lag_from_params(monkeypatch: pytest.MonkeyPatch) -> None:
+def test_legacy_lag_allowance_param_is_ignored(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Мёртвая ручка default_params.lag_allowance_days не может вернуть календарь.
+
+ Строка монитора в проде всё ещё несёт {"lag_allowance_days": 25} (миграция 180).
+ С любым её значением вердикт один и тот же — порог берётся из такта загрузки.
+ """
_patch_runs(monkeypatch)
- db = _FakeDB(_MAY_2026)
- # lag=0 → порог = sber_index_max_age_days (35) → @2026-07-12 (age=72) просрочено.
- out = mon.check_sber_freshness(
- db, run_id=4, params={"lag_allowance_days": 0}, now=_now(2026, 7, 12)
- ) # type: ignore[arg-type]
- assert out["alert"] == 1
+ base = mon.check_sber_freshness(_prod_db(), run_id=8, params={}, now=_PROD_NOW) # type: ignore[arg-type]
+ tweaked = mon.check_sber_freshness(
+ _prod_db(), # type: ignore[arg-type]
+ run_id=9,
+ params={"lag_allowance_days": 0},
+ now=_PROD_NOW,
+ )
+ assert base["alert"] == tweaked["alert"] == 0
-def test_check_default_threshold_uses_setting_plus_lag(monkeypatch: pytest.MonkeyPatch) -> None:
- """Дефолтный порог = sber_index_max_age_days + DEFAULT_LAG_ALLOWANCE_DAYS."""
+def test_threshold_read_from_pull_schedule(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Порог читается из строки загрузчика: такт 28 → порог 56, тревоги нет."""
_patch_runs(monkeypatch)
- db = _FakeDB(_MAY_2026)
- threshold = settings.sber_index_max_age_days + mon.DEFAULT_LAG_ALLOWANCE_DAYS
- # Ровно на пороге (age == threshold) — алерта нет; +1 день — алерт.
- exact = datetime(2026, 5, 1, tzinfo=UTC) + timedelta(days=threshold)
- out_exact = mon.check_sber_freshness(db, run_id=5, params={}, now=exact) # type: ignore[arg-type]
- assert out_exact["age_days"] == threshold
- assert out_exact["alert"] == 0
- out_over = mon.check_sber_freshness(db, run_id=6, params={}, now=exact + timedelta(days=1)) # type: ignore[arg-type]
- assert out_over["alert"] == 1
+ db = _FakeDB(
+ {"real_estate_deals": _JUN_2026},
+ last_pull=_now(2026, 7, 23), # 20 суток
+ interval_days="28",
+ )
+ out = mon.check_sber_freshness(db, run_id=10, params={}, now=_PROD_NOW) # type: ignore[arg-type]
+ assert out["max_pull_lag_days"] == 56
+ assert out["alert"] == 0
+
+
+def test_missing_schedule_row_falls_back_to_default(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Строки/ключа нет — берём DEFAULT_PULL_INTERVAL_DAYS, а не падаем."""
+ _patch_runs(monkeypatch)
+ db = _FakeDB({"real_estate_deals": _JUN_2026}, interval_days=None)
+ out = mon.check_sber_freshness(db, run_id=11, params={}, now=_PROD_NOW) # type: ignore[arg-type]
+ assert out["max_pull_lag_days"] == mon.MISSED_PULL_CYCLES * mon.DEFAULT_PULL_INTERVAL_DAYS
# ── Миграция 180 ──────────────────────────────────────────────────────────────
@@ -208,22 +375,18 @@ def test_migration_180_window_9_to_10_utc() -> None:
assert re.search(r"\b10\b", sql), "window_end_hour 10 missing"
-def test_migration_180_lag_allowance_25() -> None:
- sql = _MIGRATION_180.read_text("utf-8")
- assert "lag_allowance_days" in sql
- assert "25" in sql
-
-
def test_migration_180_no_psycopg_trap() -> None:
sql = _MIGRATION_180.read_text("utf-8")
assert not re.search(r":\w+::", sql)
-# ── Миграция 212: такт загрузки не должен пересекать порог монитора ───────────
+# ── Миграция 212: такт загрузки = источник порога ─────────────────────────────
#
-# Прод-разбор (ревью PR #2681): загрузка раз в 28 дней давала возраст-пилу 46..74
-# при пороге 60 — тревога срабатывала 14 суток из 28 БЕЗ всякого застоя источника.
-# Тест держит инвариант: потолок возраста (пол + такт загрузки) < порога монитора.
+# Прежний инвариант («пол возраста + такт < календарного порога монитора») снят
+# вместе с календарным порогом: прод его ОПРОВЕРГ — 2026-08-12 возраст 72 при
+# полном прогоне шестидневной давности, потолок 53 держался бы только если бы
+# источник публиковал строго помесячно. Остаётся то, что проверяемо: фолбэк кода
+# не должен расходиться с тактом, который сеет миграция.
def test_migration_212_makes_pull_cadence_weekly() -> None:
@@ -234,23 +397,22 @@ def test_migration_212_makes_pull_cadence_weekly() -> None:
assert not re.search(r":\w+::", sql) # psycopg v3: только CAST(:x AS type)
-def test_pull_cadence_leaves_margin_under_monitor_threshold() -> None:
- """Инвариант: пол возраста + такт загрузки < порога монитора.
-
- Пол = 46 суток (прод 2026-07-17: загрузка принесла 2026-06-01). Порог =
- sber_index_max_age_days + lag_allowance. При такте 7: 46+7=53 < 60 — запас
- 7 суток. При прежних 28: 46+28=74 > 60 — тревога каждый цикл, что и наблюдали.
- """
+def test_default_pull_interval_matches_migration_212() -> None:
+ """Фолбэк монитора == такт из миграции, иначе порог тихо разъедется с загрузкой."""
interval_days = int(
- re.search(r'"interval_days":\s*(\d+)', _MIGRATION_212.read_text("utf-8")).group(1)
- )
- observed_floor_days = 46
- threshold = settings.sber_index_max_age_days + mon.DEFAULT_LAG_ALLOWANCE_DAYS
- assert observed_floor_days + interval_days < threshold, (
- f"такт {interval_days}д даёт потолок возраста "
- f"{observed_floor_days + interval_days}д при пороге {threshold}д — "
- "монитор снова будет мерить наш такт, а не застой источника"
+ re.search(r'"interval_days":\s*(\d+)', _MIGRATION_212.read_text("utf-8")).group(1) # type: ignore[union-attr]
)
+ assert mon.DEFAULT_PULL_INTERVAL_DAYS == interval_days
+
+
+# ── Один порог, а не два ──────────────────────────────────────────────────────
+
+
+def test_no_second_calendar_threshold_in_settings() -> None:
+ """#2846: sber_index_max_age_days удалён — второму порогу неоткуда взяться."""
+ from app.core.config import settings
+
+ assert not hasattr(settings, "sber_index_max_age_days")
# ── Регистрация в kit registry ─────────────────────────────────────────────────
diff --git a/tradein-mvp/backend/tests/test_yandex_history_area_filter.py b/tradein-mvp/backend/tests/test_yandex_history_area_filter.py
index 44d6ac26..a21a9509 100644
--- a/tradein-mvp/backend/tests/test_yandex_history_area_filter.py
+++ b/tradein-mvp/backend/tests/test_yandex_history_area_filter.py
@@ -32,8 +32,8 @@ def _history_rows(db) -> list[dict]:
"""Строки батча house_placement_history из мока сессии.
#2674: раньше тесты брали `db.execute.call_args_list[0]` — позиционно. Позиция
- сломалась, как только у функции появился второй execute (UPDATE houses.has_panorama
- перед вставкой истории). Фильтруем по SQL: тест переживёт любой новый вызов.
+ сломалась, как только у функции появился второй execute перед вставкой истории.
+ Фильтруем по SQL: тест переживёт любой новый вызов.
"""
for call in db.execute.call_args_list:
if "INSERT INTO house_placement_history" in str(call.args[0]):
@@ -169,10 +169,9 @@ def test_all_invalid_area_returns_zero_no_crash() -> None:
assert saved == 0
# db.execute не должен вызываться для пустого rows (нет INSERT)
assert _history_rows(db) == []
- # Commit вызывается, rollback — нет. Два коммита: пустой батч истории + запись
- # houses.has_panorama (#2674) — наблюдение о доме не зависит от того, отфильтровалась
- # ли история по площади.
- assert db.commit.call_count == 2
+ # Commit вызывается один раз (пустой батч истории), rollback — нет. Второй коммит
+ # (запись houses.has_panorama) ушёл вместе с колонкой — хвост #2674, мигр. 259.
+ assert db.commit.call_count == 1
db.rollback.assert_not_called()
diff --git a/tradein-mvp/backend/tests/test_yandex_valuation.py b/tradein-mvp/backend/tests/test_yandex_valuation.py
index 21d6f605..c6196e84 100644
--- a/tradein-mvp/backend/tests/test_yandex_valuation.py
+++ b/tradein-mvp/backend/tests/test_yandex_valuation.py
@@ -1,7 +1,7 @@
"""Unit tests for YandexValuationScraper — anonymous house-history scraper.
Fixture HTML simulates the Yandex valuation page body text containing:
-- House meta block (year, floors, type, ceiling, lift, total objects, panorama)
+- House meta block (year, floors, type, ceiling, lift, total objects)
- 2-3 historical offer entries with full structure
Легаси `app.services.scrapers.yandex_valuation` удалён (#2277 финальный шаг
@@ -78,7 +78,6 @@ def test_parse_house_meta_full():
assert meta.ceiling_height == 2.50
assert meta.has_lift is True
assert meta.total_objects == 12
- assert meta.has_panorama is False
def test_parse_house_meta_no_lift():
@@ -91,10 +90,16 @@ def test_parse_house_meta_no_lift():
assert meta.ceiling_height == 3.0
-def test_parse_house_meta_with_panorama():
- text = "7 объектов Дом 2010 года Панорама Лифт Кирпичное здание"
+def test_parse_house_meta_brick_with_lift():
+ """#2674 (хвост): фикстура больше не содержит «Панорама».
+
+ Тест назывался ..._with_panorama и кормил парсеру строку, собранную автором;
+ зелёный он был по построению. На настоящей странице оценки этого слова нет —
+ проверено боевым трактом 13.08.2026, 0 вхождений в полном HTML трёх адресов.
+ Остаток теста осмысленный: кирпич + лифт на короткой мета-строке.
+ """
+ text = "7 объектов Дом 2010 года Лифт Кирпичное здание"
meta = YandexValuationScraper._parse_house_meta(text)
- assert meta.has_panorama is True
assert meta.has_lift is True
assert meta.house_type == "brick"
@@ -275,7 +280,7 @@ def test_total_floors_extracted_from_dom_meta_not_items():
Real Yandex page has 'M этажей' (plural) in dom-meta and 'N этаж' (singular) per item.
"""
text = (
- "Дом 2025 года Панорама 25 этажей Монолитное здание 2,7 м потолки Лифт "
+ "Дом 2025 года 25 этажей Монолитное здание 2,7 м потолки Лифт "
"1-комнатная 40 м² 3 этаж 10.01.2026 В экспозиции 5 дней В продаже "
"2-комнатная 55 м² 17 этаж 05.01.2026 В экспозиции 10 дней В продаже"
)
diff --git a/tradein-mvp/backend/tests/test_yandex_valuation_save.py b/tradein-mvp/backend/tests/test_yandex_valuation_save.py
index 308d4cb1..4b90d5ef 100644
--- a/tradein-mvp/backend/tests/test_yandex_valuation_save.py
+++ b/tradein-mvp/backend/tests/test_yandex_valuation_save.py
@@ -30,8 +30,8 @@ def _history_rows(db) -> list[dict]:
"""Строки батча house_placement_history из мока сессии.
#2674: раньше тесты брали `db.execute.call_args_list[0]` — позиционно. Позиция
- сломалась, как только у функции появился второй execute (UPDATE houses.has_panorama
- перед вставкой истории). Фильтруем по SQL: тест переживёт любой новый вызов.
+ сломалась, как только у функции появился второй execute перед вставкой истории.
+ Фильтруем по SQL: тест переживёт любой новый вызов.
"""
for call in db.execute.call_args_list:
if "INSERT INTO house_placement_history" in str(call.args[0]):
diff --git a/tradein-mvp/frontend/src/app/ui-preview/estimate/fixture.ts b/tradein-mvp/frontend/src/app/ui-preview/estimate/fixture.ts
index e441325a..ec10ab75 100644
--- a/tradein-mvp/frontend/src/app/ui-preview/estimate/fixture.ts
+++ b/tradein-mvp/frontend/src/app/ui-preview/estimate/fixture.ts
@@ -175,6 +175,10 @@ export const FIXTURE_ESTIMATE: AggregatedEstimate = {
median_ppm2: 162_000,
high_ppm2: 178_000,
period_months: 24,
+ // #2846: свежайшая сделка ВЫБОРКИ. Совпадает с прод-максимумом на 2026-08-12
+ // (deals.max(deal_date) = 2026-01-01) — оффлайн-превью должно показывать
+ // ту же подпись «по I кв. 2026», что и живая страница.
+ latest_deal_date: "2026-01-01",
},
price_trend: [
{ month: "2025-11", ppm2: 168_000 },
diff --git a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx
index 1b89abe9..652bd91e 100644
--- a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx
+++ b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx
@@ -11,6 +11,7 @@ import type { AggregatedEstimate, TradeInEstimateInput, HouseType, RepairState,
import { asHouseType, asRepairState } from "@/types/trade-in";
import { useActiveBrandSlug, useBrand } from "@/lib/useBrand";
import { sourceDot, sourceLabel } from "@/lib/source-registry";
+import { dealsAsOfLabel } from "@/lib/rosreestr";
import { HeroTransparency } from "./HeroTransparency";
interface Props {
@@ -175,6 +176,11 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
const dkpArea = estimate.area_m2 ?? input.area_m2 ?? 0;
const showDkp =
!!dkp && dkp.count > 0 && dkpArea > 0 && dkp.low_ppm2 > 0 && dkp.high_ppm2 > 0;
+ // #2846: возраст ОТОБРАННЫХ сделок. Раньше здесь стояло «за {period_months}
+ // мес» — окно ПОИСКА, выданное за период данных: «12 мес» читалось как «сделки
+ // по сегодня», при том что свежайшая пачка Росреестра на проде — I кв. 2026.
+ // null (оценка старше деплоя бэкенда, поля нет) → хвост просто исчезает.
+ const dkpAsOf = dealsAsOfLabel(dkp?.latest_deal_date, "quarter");
// Progressive enrichment state
const needsHouseType = estimate.house_type === null;
@@ -547,6 +553,7 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
{dkp.median_ppm2.toLocaleString("ru-RU")} ₽/м² · {dkp.count} сд.
+ {dkpAsOf ? ` · ${dkpAsOf}` : ""}
)}
@@ -601,8 +608,8 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
{formatMln(dkp.low_ppm2 * dkpArea)} – {formatMln(dkp.high_ppm2 * dkpArea)} ₽
- {dkp.median_ppm2.toLocaleString("ru-RU")} ₽/м² · {dkp.count}
ДКП за{" "}
- {dkp.period_months} мес
+ {dkp.median_ppm2.toLocaleString("ru-RU")} ₽/м² · {dkp.count}
ДКП
+ {dkpAsOf ? ` · ${dkpAsOf}` : ""}
)}
diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/dealsAsOf.test.ts b/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/dealsAsOf.test.ts
new file mode 100644
index 00000000..9b8f6480
--- /dev/null
+++ b/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/dealsAsOf.test.ts
@@ -0,0 +1,175 @@
+// #2846 — плитка «ДКП · РОСРЕЕСТР (ФАКТИЧЕСКИЕ СДЕЛКИ)» обязана назвать возраст.
+//
+// До правки автор mappers.ts честно писал: «No "ЗА N МЕСЯЦЕВ" — we have no honest
+// period to show», потому что единственным кандидатом был period_months — ОКНО
+// ПОИСКА. Он был прав про поле и неправ про молчание: слово «ФАКТИЧЕСКИЕ»
+// читается как «недавние», а свежайшая пачка Росреестра на проде (замер
+// 2026-08-12) — I кв. 2026, 223 дня от её начала.
+//
+// Плитку кормят ТРИ РАЗНЫЕ выборки (street-deals → dkp_corridor → actual_deals),
+// поэтому здесь проверяется каждая: подпись обязана описывать тот набор, чьи
+// числа сейчас на экране, а не тот, который удобнее.
+
+import { describe, expect, it } from "vitest";
+
+import { mapResultPanel } from "../mappers";
+import { dealsAsOfLabel } from "@/lib/rosreestr";
+import { FIXTURE_ESTIMATE } from "@/app/ui-preview/estimate/fixture";
+import type {
+ AggregatedEstimate,
+ AnalogLot,
+ StreetDealsResponse,
+} from "@/types/trade-in";
+
+/** ppm-строка плитки ДКП — там же, где «₽/м² · N сделок». */
+function dkpPpm(
+ e: AggregatedEstimate,
+ sd: StreetDealsResponse | null = null,
+): string {
+ const card = mapResultPanel(e, sd).cards.find((c) =>
+ c.title.join(" ").includes("ДКП"),
+ );
+ if (!card) throw new Error("плитка ДКП пропала из панели результата");
+ return card.ppm;
+}
+
+function deal(over: Partial): AnalogLot {
+ return {
+ address: "ул. Гоголя, 18",
+ area_m2: 60,
+ rooms: 2,
+ floor: 4,
+ total_floors: 9,
+ price_rub: 12_000_000,
+ price_per_m2: 200_000,
+ listing_date: "2026-01-01",
+ days_on_market: null,
+ photo_url: null,
+ source: "rosreestr",
+ source_url: null,
+ distance_m: null,
+ tier: "T1_per_street",
+ date_precision: "quarter",
+ lat: null,
+ lon: null,
+ ...over,
+ };
+}
+
+function streetDeals(deals: AnalogLot[]): StreetDealsResponse {
+ return {
+ street: "Гоголя",
+ period_from: "2025-08-12",
+ period_to: "2026-08-12",
+ count: deals.length,
+ median_price_rub: 12_000_000,
+ median_price_per_m2: 200_000,
+ range_low_rub: 10_000_000,
+ range_high_rub: 14_000_000,
+ deals,
+ };
+}
+
+// Ветка 2: только коридор, без street-deals и без actual_deals.
+const CORRIDOR_ONLY: AggregatedEstimate = {
+ ...FIXTURE_ESTIMATE,
+ actual_deals: [],
+ area_m2: 60,
+ dkp_corridor: {
+ count: 9,
+ low_ppm2: 150_000,
+ median_ppm2: 162_000,
+ high_ppm2: 178_000,
+ period_months: 24,
+ latest_deal_date: "2026-01-01",
+ },
+};
+
+describe("#2846 возраст выборки ДКП на плитке", () => {
+ it("коридор: возраст выборки доезжает до ppm-строки", () => {
+ // Красный на origin/main: latest_deal_date некуда положить и некому прочесть.
+ expect(dkpPpm(CORRIDOR_ONLY)).toContain("по I кв. 2026");
+ });
+
+ it("подпись называет ВЕРХНЮЮ границу свежести, а не окно поиска", () => {
+ // period_months = 24 у той же карточки. «за 24 месяца» при сделках января —
+ // ровно та подмена, из-за которой подписи не было вовсе.
+ const ppm = dkpPpm(CORRIDOR_ONLY);
+ expect(ppm).not.toContain("24");
+ expect(ppm).not.toContain("мес");
+ });
+
+ it("street-deals: подпись берётся у выборки, которая реально на экране", () => {
+ // Эта ветка приоритетнее коридора в resolveDealTier. Коридор тут заявляет
+ // I кв. 2026 — если бы подпись читалась из него, она омолодила бы чужие числа.
+ const ppm = dkpPpm(
+ CORRIDOR_ONLY,
+ streetDeals([
+ deal({ listing_date: "2025-10-01" }),
+ deal({ listing_date: "2025-07-01" }),
+ ]),
+ );
+ expect(ppm).toContain("по IV кв. 2025");
+ expect(ppm).not.toContain("2026");
+ });
+
+ it("actual_deals: третья ветка тоже датирована", () => {
+ const ppm = dkpPpm({
+ ...FIXTURE_ESTIMATE,
+ dkp_corridor: null,
+ actual_deals: [
+ deal({ listing_date: "2025-04-01" }),
+ deal({ listing_date: "2025-07-01" }),
+ ],
+ });
+ expect(ppm).toContain("по III кв. 2025");
+ });
+
+ it("сделок нет вовсе — подписи нет, а не «по …» под прочерком", () => {
+ const ppm = dkpPpm({
+ ...FIXTURE_ESTIMATE,
+ dkp_corridor: null,
+ actual_deals: [],
+ });
+ expect(ppm).toBe("—");
+ });
+
+ it("оценка старше деплоя бэкенда (поля нет) — молчим, как раньше", () => {
+ const legacy = { ...CORRIDOR_ONLY.dkp_corridor! };
+ delete (legacy as { latest_deal_date?: string | null }).latest_deal_date;
+ const ppm = dkpPpm({ ...CORRIDOR_ONLY, dkp_corridor: legacy });
+ expect(ppm).toContain("сделок");
+ expect(ppm).not.toContain("кв.");
+ });
+});
+
+describe("#2846 форма подписи", () => {
+ it("все 9 живых deal_date прода → римский квартал", () => {
+ // Не выдумано: ровно `SELECT DISTINCT deal_date FROM deals` на 2026-08-12.
+ expect(dealsAsOfLabel("2026-01-01")).toBe("по I кв. 2026");
+ expect(dealsAsOfLabel("2025-10-01")).toBe("по IV кв. 2025");
+ expect(dealsAsOfLabel("2025-07-01")).toBe("по III кв. 2025");
+ expect(dealsAsOfLabel("2025-04-01")).toBe("по II кв. 2025");
+ expect(dealsAsOfLabel("2025-01-01")).toBe("по I кв. 2025");
+ expect(dealsAsOfLabel("2024-10-01")).toBe("по IV кв. 2024");
+ expect(dealsAsOfLabel("2024-07-01")).toBe("по III кв. 2024");
+ expect(dealsAsOfLabel("2024-04-01")).toBe("по II кв. 2024");
+ expect(dealsAsOfLabel("2024-01-01")).toBe("по I кв. 2024");
+ });
+
+ it("date-only ISO не уезжает на предыдущий квартал в западных зонах", () => {
+ // "2026-01-01" — UTC-полночь; локальные геттеры дали бы 31.12.2025 → IV кв.
+ expect(dealsAsOfLabel("2026-01-01")).toBe("по I кв. 2026");
+ expect(dealsAsOfLabel("2026-04-01")).toBe("по II кв. 2026");
+ });
+
+ it("источник с настоящей датой подписывается месяцем, а не кварталом", () => {
+ expect(dealsAsOfLabel("2026-05-12", "day")).toBe("по 05.2026");
+ });
+
+ it("датировать нечего — null, а не заглушка", () => {
+ expect(dealsAsOfLabel(null)).toBeNull();
+ expect(dealsAsOfLabel(undefined)).toBeNull();
+ expect(dealsAsOfLabel("не дата")).toBeNull();
+ });
+});
diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts
index 2af10439..777918d1 100644
--- a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts
+++ b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts
@@ -76,6 +76,7 @@ import type {
} from "./types";
import { tokens } from "./tokens";
import { sourceLabel as registrySourceLabel } from "@/lib/source-registry";
+import { dealsAsOfLabel } from "@/lib/rosreestr";
// Total number of source slots — meta "N / M". Single source of truth is
// SOURCE_SLOTS.length (defined with the array below as TOTAL_SOURCES).
@@ -599,12 +600,27 @@ interface DealTier {
medianPpm: number;
count: number;
bars: number[];
+ /** #2846: «по I кв. 2026» — as-of ТОГО набора сделок, который дал числа выше. */
+ asOf: string | null;
+}
+
+/** Свежайшая дата среди лотов + её точность (лоты не всегда отсортированы). */
+function newestLot(lots: AnalogLot[]): [string | null, "day" | "quarter" | null] {
+ let best: AnalogLot | null = null;
+ for (const l of lots) {
+ if (l.listing_date && (!best?.listing_date || l.listing_date > best.listing_date)) best = l;
+ }
+ return [best?.listing_date ?? null, best?.date_precision ?? null];
}
/**
* Resolve the "ФАКТИЧЕСКИЕ СДЕЛКИ" tier, preferring (1) street DKP deals with
* real ₽ totals, then (2) the dkp_corridor ₽/м² × area, then (3) the estimate's
* own actual_deals. null when no deal data at all.
+ *
+ * #2846: каждая ветка тянет СВОЙ as-of — три ветки это три РАЗНЫЕ выборки
+ * (street-deals не режет ppm²-выбросы, коридор режет и умеет расширяться до
+ * города), и подпись обязана описывать ту, чьи числа сейчас на экране.
*/
function resolveDealTier(
e: AggregatedEstimate,
@@ -621,6 +637,9 @@ function resolveDealTier(
// same way the ads card does — otherwise a single mis-scraped lot bins
// over [min,max] and crushes the real deals into the left buckets.
bars: bins8(guardPriceOutliers(sd.deals.map((d) => d.price_per_m2)).clean),
+ // sd.deals — top-10 из ORDER BY deal_date DESC по всем sd.count сделкам,
+ // так что максимум по ним = максимум по всей выборке, не по показанным.
+ asOf: dealsAsOfLabel(...newestLot(sd.deals)),
};
}
const area = e.area_m2;
@@ -636,6 +655,9 @@ function resolveDealTier(
bars: bins8(
guardPriceOutliers(e.actual_deals.map((d) => d.price_per_m2)).clean,
),
+ // Не из actual_deals: гистограмма заимствует их только как форму, а
+ // числа карточки (медиана/границы) — из коридора, у которого своя выборка.
+ asOf: dealsAsOfLabel(c.latest_deal_date, "quarter"),
};
}
if (e.actual_deals.length > 0) {
@@ -652,6 +674,7 @@ function resolveDealTier(
count: e.actual_deals.length,
// Fix #1/#8: same high-outlier guard as the ads card (see above).
bars: bins8(guardPriceOutliers(ppms).clean),
+ asOf: dealsAsOfLabel(...newestLot(e.actual_deals)),
};
}
return null;
@@ -1135,11 +1158,26 @@ export function mapResultPanel(
range: dealTier
? rangeLine(dealTier.loRub, dealTier.hiRub)
: "нет данных",
+ // #2846: хвост «· по I кв. 2026» — возраст ОТОБРАННЫХ сделок. Слово
+ // «ФАКТИЧЕСКИЕ» в заголовке читается как «недавние», а свежайшая пачка
+ // Росреестра на проде — I кв. 2026; молчание тут обещало свежесть,
+ // которой нет. Отдельной строки не заводим: ppm-строка по контракту
+ // ResultCard уже носит хвост «· N сделок», третий факт про тот же набор
+ // встаёт в тот же ряд и читается одним взглядом.
+ // dealTier == null (сделок нет) → «—», подписи нет: без чисел датировать
+ // нечего, а «по I кв. 2026» под прочерком выглядело бы как скрытые данные.
ppm: dealTier
- ? `${fmtPpm(dealTier.medianPpm)} · ${dealTier.count} ${pluralRu(
- dealTier.count,
- ["сделка", "сделки", "сделок"],
- )}`
+ ? [
+ fmtPpm(dealTier.medianPpm),
+ `${dealTier.count} ${pluralRu(dealTier.count, [
+ "сделка",
+ "сделки",
+ "сделок",
+ ])}`,
+ dealTier.asOf,
+ ]
+ .filter(Boolean)
+ .join(" · ")
: "—",
bars: dealTier ? dealTier.bars : [],
nav: 1,
diff --git a/tradein-mvp/frontend/src/lib/rosreestr.ts b/tradein-mvp/frontend/src/lib/rosreestr.ts
index af89b100..6c2aa52e 100644
--- a/tradein-mvp/frontend/src/lib/rosreestr.ts
+++ b/tradein-mvp/frontend/src/lib/rosreestr.ts
@@ -1,6 +1,41 @@
export const ROSREESTR_REQUEST_URL =
"https://rosreestr.gov.ru/eservices/request_info_from_egrn/";
+const ROMAN_QUARTER = ["I", "II", "III", "IV"] as const;
+
+/**
+ * #2846: «по I кв. 2026» — до какого момента доходят ПОКАЗАННЫЕ ДКП-сделки.
+ *
+ * Гранулярность — квартал, и это не округление ради красоты: Росреестр
+ * публикует ДКП пачками, deal_date = первый день квартала (бэкенд помечает это
+ * `date_precision: "quarter"`, #1995). Прод-замер 2026-08-12: 96 974 сделки,
+ * 9 различных deal_date, day-of-month = 1 у 100%, месяцы ровно {01,04,07,10}.
+ * Поэтому «223 дня назад» было бы ложной точностью в сторону состаривания:
+ * сделка из пачки «2026-01-01» могла случиться и 31 марта. «по I кв. 2026» —
+ * ровно то, что известно, и ни бита сверх.
+ *
+ * «по» (а не «за») намеренно: выборка обычно накрывает несколько кварталов,
+ * и подпись называет ВЕРХНЮЮ границу свежести, а не период целиком.
+ *
+ * @param iso ISO-дата свежайшей сделки ВЫБОРКИ (не всей таблицы).
+ * @param precision 'day' у источника с настоящей датой; иначе квартал.
+ * @returns null, если датировать нечего — подпись тогда не рисуется вовсе.
+ */
+export function dealsAsOfLabel(
+ iso: string | null | undefined,
+ precision?: "day" | "quarter" | null,
+): string | null {
+ if (!iso) return null;
+ const d = new Date(iso);
+ if (Number.isNaN(d.getTime())) return null;
+ // ISO date-only ("2026-01-01") парсится как UTC-полночь — читаем UTC-поля,
+ // иначе в отрицательных зонах квартал уезжает на предыдущий.
+ if (precision === "day") {
+ return `по ${String(d.getUTCMonth() + 1).padStart(2, "0")}.${d.getUTCFullYear()}`;
+ }
+ return `по ${ROMAN_QUARTER[Math.floor(d.getUTCMonth() / 3)]} кв. ${d.getUTCFullYear()}`;
+}
+
export async function openRosreestrWithAddress(address: string): Promise {
try {
await navigator.clipboard.writeText(address);
diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts
index 38ecd503..c136e129 100644
--- a/tradein-mvp/frontend/src/types/trade-in.ts
+++ b/tradein-mvp/frontend/src/types/trade-in.ts
@@ -110,6 +110,10 @@ export interface AnalogLot {
// 'T0_per_house' — kadastr_num exact match (currently not available in open dataset)
// 'T1_per_street' — street-level only (default for all rosreestr deals)
tier?: string | null;
+ // #1995 (бэкенд уже слал, фронт игнорировал; поднято #2846): честная точность
+ // listing_date. 'quarter' — rosreestr: дата = первый день КВАРТАЛА, а не день
+ // сделки. 'day' — объявления. undefined у старых оценок и когда source неизвестен.
+ date_precision?: "day" | "quarter" | null;
// ── ANALYTICS surface — гео-координаты лота для карты (MapCard). Optional +
// nullable: старые оценки и лоты без геокодинга их не содержат. Пропускаем
// пины с null lat/lon.
@@ -153,7 +157,12 @@ export interface DkpCorridor {
low_ppm2: number; // P10 ₽/м² по сделкам (робастный коридор)
median_ppm2: number; // медиана ₽/м²
high_ppm2: number; // P90 ₽/м² по сделкам (робастный коридор)
- period_months: number; // окно поиска сделок
+ period_months: number; // окно ПОИСКА сделок — НЕ возраст данных
+ // #2846: ISO-дата свежайшей из ОТОБРАННЫХ сделок (не из всей таблицы). Точность
+ // — квартал: Росреестр публикует deal_date = первым днём квартала, поэтому
+ // «2026-01-01» читается как «I кв. 2026», а не как 1 января. optional: оценки,
+ // посчитанные до деплоя бэкенда, поля не содержат → подпись просто не рисуется.
+ latest_deal_date?: string | null;
}
export interface AggregatedEstimate {
diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py
index 034de30f..e5421aec 100644
--- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py
+++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py
@@ -61,7 +61,22 @@ _PROBE_URLS: dict[str, str] = {
"avito": "https://www.avito.ru/robots.txt",
"cian": "https://ekb.cian.ru/robots.txt", # рабочий хост — providers/cian/serp.py base_url
"yandex": "https://realty.yandex.ru/robots.txt", # providers/yandex/serp.py::_GATE_URL
- "domclick": "https://bff-search-web.domclick.ru/robots.txt",
+ # #2855: боевой ЗАЩИЩЁННЫЙ путь, а не robots.txt того же хоста. QRATOR закрывает
+ # /api/offers/*, а robots.txt отдаётся свободно — проба по нему возвращала 200 ровно
+ # тогда, когда свип с того же узла получал блок-страницу. Замер 13.08: за сутки
+ # 64 проверки пар и 5 банов на ВСЕ площадки, при этом свип Домклика блокировался
+ # каждые сутки; 04:30 проба «pair_banned=0» → 05:02 свип «QRATOR block».
+ # Взят count-эндпоинт: та же семья путей, что у работы (providers/domclick/serp.py
+ # ::_build_count_url), но ответ — одно число, без пагинации и без выдачи, то есть
+ # проба остаётся дешёвой для площадки (тот же довод, по которому здесь /fetch, а
+ # не /fetch-json). Параметры продублированы намеренно: импорт провайдера сюда дал
+ # бы цикл (providers/* уже импортируют browser_fetcher).
+ "domclick": (
+ "https://bff-search-web.domclick.ru/api/offers/count/v1"
+ "?address=0d475b79-88de-4054-818c-37d8f9d0d440&aids=20561"
+ "&deal_type=sale&category=living&offer_type=flat&rooms=st"
+ "&sort=qi&sort_dir=desc"
+ ),
}
_PROXY_PROBE_URL: str = _PROBE_URLS["avito"]
# Источники, по которым вообще есть что спрашивать. Публичный кортеж — proxy_pool
@@ -75,6 +90,11 @@ PROBE_SOURCES: tuple[str, ...] = tuple(_PROBE_URLS)
# странице-заглушке — самый дешёвый способ отличить «отдали ресурс» от «отдали
# отказ с кодом 200».
_PROBE_CONTENT_MARKER: str = "User-agent"
+# #2855: источник, чья проба идёт НЕ по robots.txt, требует своего признака «ресурс
+# отдан». У count-эндпоинта Домклика это ключ ответа `snippetsCount` — он есть в живом
+# JSON и отсутствует в блок-странице QRATOR. Держим отдельной картой, а не условием в
+# теле: добавить площадку с боевым путём должно быть одной строкой, а не веткой.
+_PROBE_CONTENT_MARKERS: dict[str, str] = {"domclick": "snippetsCount"}
# source='generic' В ТЕЛЕ /fetch НАМЕРЕННО, какой бы площадке ни принадлежал адрес:
# сайдкар держит по инстансу camoufox на провайдера с отдельным локом, и проба с
# source='avito' забирала бы лок боевого инстанса и релончила его (прокси пробы ≠
@@ -220,14 +240,16 @@ async def probe_proxy_via_browser(
html = ""
if not html:
return False, classify_browser_probe(resp.status_code, detail), "empty html"
- if _PROBE_CONTENT_MARKER not in html:
- # 200 и непустое тело, но это не robots.txt — площадка отдала заглушку
- # ЭТОМУ exit-IP (замер 09.08: Циан через узел id=1 → 374 КБ «Ошибка — Циан»).
+ marker = _PROBE_CONTENT_MARKERS.get(source, _PROBE_CONTENT_MARKER)
+ if marker not in html:
+ # 200 и непустое тело, но это не запрошенный ресурс — площадка отдала заглушку
+ # ЭТОМУ exit-IP (замер 09.08: Циан через узел id=1 → 374 КБ «Ошибка — Циан»;
+ # 13.08: Домклик отдаёт блок-страницу QRATOR на /api/offers/*, #2855).
# Тракт узла исправен, негодна пара — отсюда "page", а не "proxy".
return (
False,
"page",
- f"not robots.txt (html_len={len(html)}): {' '.join(html.split())[:120]}",
+ f"no marker {marker!r} (html_len={len(html)}): {' '.join(html.split())[:120]}",
)
return True, None, f"html_len={len(html)}"
diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py
index 5c090d75..b3d462d5 100644
--- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py
+++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py
@@ -2953,6 +2953,11 @@ class CianFullLoadCounters:
detail_enriched: int = 0
detail_failed: int = 0
errors_count: int = 0
+ # Бакеты, отданные SERP-слоем как НЕполные (страница выпала / исключение
+ # проглочено / hard-cap): лоты сохранены, но в done_buckets бакет не попал.
+ # Без этого счётчика «сколько бакетов чекпоинт не покрывает» видно только грепом
+ # логов, которые теряются при редеплое.
+ partial_buckets: int = 0
def to_dict(self) -> dict[str, int]:
return {f.name: getattr(self, f.name) for f in fields(self)}
@@ -3007,8 +3012,32 @@ async def run_cian_full_load(
done: set[str] = set(skip_set) # накапливаем завершённые бакеты этого прогона
- def _on_bucket(bucket_key: str, lots: list) -> None: # type: ignore[type-arg]
- """Инкрементальный save после каждого leaf-бакета. Дописывает bucket_key в done."""
+ def _mark_bucket(bucket_key: str, complete: bool) -> None:
+ """В чекпоинт — только ПОЛНОСТЬЮ собранный бакет; частичный лишь считаем.
+
+ Ключ done_buckets ("room:lo:hi") одинаков у целого и у недособранного бакета,
+ поэтому признак полноты приезжает отдельным аргументом из SERP-слоя. Пропуск
+ частичного бакета на следующем прогоне означал бы, что его непрочитанные
+ страницы не перечитает уже никто, а счётчики покажут успех.
+ """
+ if complete:
+ done.add(bucket_key)
+ return
+ counters.partial_buckets += 1
+ logger.warning(
+ "cian-full-load run_id=%d: bucket=%s собран ЧАСТИЧНО — в чекпоинт НЕ пишем, "
+ "следующий прогон перечитает его целиком (partial_buckets=%d)",
+ run_id,
+ bucket_key,
+ counters.partial_buckets,
+ )
+
+ def _on_bucket(bucket_key: str, lots: list, complete: bool = True) -> None: # type: ignore[type-arg]
+ """Инкрементальный save после каждого leaf-бакета.
+
+ complete=False → лоты сохраняем, бакет в чекпоинт не пишем
+ (см. _mark_bucket). Дефолт True — для вызывающих без пагинации.
+ """
nonlocal done
if runs.is_cancelled(db, run_id):
logger.info("cian-full-load run_id=%d: cancel detected in on_bucket", run_id)
@@ -3023,7 +3052,7 @@ async def run_cian_full_load(
)
raise RuntimeError("shutdown")
if not lots:
- done.add(bucket_key)
+ _mark_bucket(bucket_key, complete)
runs.update_heartbeat(
db, run_id, {**counters.to_dict(), "done_buckets": sorted(done)}
)
@@ -3042,7 +3071,7 @@ async def run_cian_full_load(
counters.saved_inserted += inserted
counters.saved_updated += updated
counters.unique_fetched += len(lots)
- done.add(bucket_key)
+ _mark_bucket(bucket_key, complete)
runs.update_heartbeat(db, run_id, {**counters.to_dict(), "done_buckets": sorted(done)})
logger.info(
"cian-full-load run_id=%d: bucket %s saved ins=%d upd=%d total_unique=%d",
@@ -3225,7 +3254,8 @@ async def run_cian_full_load(
except NoProxyAvailableError as exc:
# #2687, см. ту же ветку в run_avito_full_load: наш отказ (пул пуст) обязан
- # сохранять done_buckets, а generic-RuntimeError → mark_failed его теряет.
+ # сохранять done_buckets, а generic-RuntimeError уходил в mark_failed, который
+ # его терял (теперь counters мержатся — runs.update_heartbeat/mark_*, #930).
logger.error("cian-full-load run_id=%d: no proxy available — %s", run_id, exc)
counters.errors_count += 1
runs.mark_banned(
@@ -3467,7 +3497,8 @@ async def run_yandex_full_load(
except NoProxyAvailableError as exc:
# #2687, см. ту же ветку в run_avito_full_load: наш отказ (пул пуст) обязан
- # сохранять done_buckets, а generic-RuntimeError → mark_failed его теряет.
+ # сохранять done_buckets, а generic-RuntimeError уходил в mark_failed, который
+ # его терял (теперь counters мержатся — runs.update_heartbeat/mark_*, #930).
logger.error("yandex-full-load run_id=%d: no proxy available — %s", run_id, exc)
counters.errors_count += 1
runs.mark_banned(
@@ -3527,6 +3558,8 @@ class AvitoFullLoadCounters:
saved_inserted: int = 0
saved_updated: int = 0
errors_count: int = 0
+ # Бакеты, отданные SERP-слоем как НЕполные — см. CianFullLoadCounters.
+ partial_buckets: int = 0
def to_dict(self) -> dict[str, int]:
return {f.name: getattr(self, f.name) for f in fields(self)}
@@ -3599,8 +3632,32 @@ async def run_avito_full_load(
)
done: set[str] = set(skip_set)
- def _on_bucket(bucket_key: str, lots: list) -> None: # type: ignore[type-arg]
- """Инкрементальный save после каждого leaf-бакета."""
+ def _mark_bucket(bucket_key: str, complete: bool) -> None:
+ """В чекпоинт — только ПОЛНОСТЬЮ собранный бакет; частичный лишь считаем.
+
+ Ключ done_buckets ("room:lo:hi") одинаков у целого и у недособранного бакета,
+ поэтому признак полноты приезжает отдельным аргументом из SERP-слоя. Пропуск
+ частичного бакета на следующем прогоне означал бы, что его непрочитанные
+ страницы не перечитает уже никто, а счётчики покажут успех.
+ """
+ if complete:
+ done.add(bucket_key)
+ return
+ counters.partial_buckets += 1
+ logger.warning(
+ "avito-full-load run_id=%d: bucket=%s собран ЧАСТИЧНО — в чекпоинт НЕ пишем, "
+ "следующий прогон перечитает его целиком (partial_buckets=%d)",
+ run_id,
+ bucket_key,
+ counters.partial_buckets,
+ )
+
+ def _on_bucket(bucket_key: str, lots: list, complete: bool = True) -> None: # type: ignore[type-arg]
+ """Инкрементальный save после каждого leaf-бакета.
+
+ complete=False → лоты сохраняем, бакет в чекпоинт не пишем
+ (см. _mark_bucket). Дефолт True — для вызывающих без пагинации.
+ """
nonlocal done
if runs.is_cancelled(db, run_id):
logger.info("avito-full-load run_id=%d: cancel detected in on_bucket", run_id)
@@ -3613,7 +3670,7 @@ async def run_avito_full_load(
)
raise RuntimeError("shutdown")
if not lots:
- done.add(bucket_key)
+ _mark_bucket(bucket_key, complete)
runs.update_heartbeat(
db, run_id, {**counters.to_dict(), "done_buckets": sorted(done)}
)
@@ -3632,7 +3689,7 @@ async def run_avito_full_load(
counters.saved_inserted += inserted
counters.saved_updated += updated
counters.unique_fetched += len(lots)
- done.add(bucket_key)
+ _mark_bucket(bucket_key, complete)
runs.update_heartbeat(db, run_id, {**counters.to_dict(), "done_buckets": sorted(done)})
logger.info(
"avito-full-load run_id=%d: bucket=%s saved ins=%d upd=%d total_unique=%d",
@@ -3699,9 +3756,10 @@ async def run_avito_full_load(
except NoProxyAvailableError as exc:
# #2687: пул опустел mid-run. Ветка стоит ДО generic-RuntimeError намеренно —
# NoProxyAvailableError его подкласс, и без неё отказ уходил в mark_failed,
- # который (в отличие от mark_banned) НЕ пишет done_buckets. То есть чекпоинт
+ # который (в отличие от mark_banned) НЕ передавал done_buckets. То есть чекпоинт
# терялся ровно на НАШЕМ отказе — том исходе, для которого #2686 требовал его
- # сохранять наравне с блокировкой площадкой.
+ # сохранять наравне с блокировкой площадкой. Диагноз ban_kind='infra' ветка
+ # даёт по-прежнему; сам чекпоинт с #930-мержем counters переживает и mark_failed.
logger.error("avito-full-load run_id=%d: no proxy available — %s", run_id, exc)
counters.errors_count += 1
runs.mark_banned(
@@ -3942,6 +4000,13 @@ async def run_domclick_city_sweep(
f"QRATOR block aborted sweep — {counters.lots_fetched} listings "
"collected before abort (#2657)",
counters.to_dict(),
+ # #2687: эта ветка входится ТОЛЬКО при распознанном QRATOR-блоке
+ # (counters.blocked == 1, DomClickBlockedError — площадка показала
+ # block-страницу). Это ровно определение BAN_KIND_PLATFORM
+ # («площадка нас заблокировала»), а не 'unknown'. Раньше домкликовый
+ # свип не передавал ban_kind и все его баны падали в 'unknown', хотя
+ # диагноз здесь установлен, а не «не установлен» (#2764).
+ ban_kind=BAN_KIND_PLATFORM,
)
elif 0 < counters.buckets_completed < counters.buckets_total:
# #2670: прогон оборван на середине — прошёл часть комнатных бакетов и
diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py
index c0996e1b..86729701 100644
--- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py
+++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py
@@ -100,8 +100,8 @@ def _leading_streak(rows: list[Any], is_bad: Callable[[Any], bool]) -> int:
# РЯДОМ со status='banned', а не ВМЕСТО него — сознательный выбор между «новый
# статус» и «явное поле причины»:
# 1. Побочная функция 'banned' — сохранение done_buckets-чекпоинта (mark_failed
-# его теряет) — нужна ОБОИМ исходам. Оставив статус, получаем её даром; расщепив
-# статус, пришлось бы дублировать её в каждом потребителе.
+# его тогда терял) — нужна ОБОИМ исходам. Оставив статус, получаем её даром;
+# расщепив статус, пришлось бы дублировать её в каждом потребителе.
# 2. Новое значение статуса пришлось бы доучить пяти местам, каждое из которых
# молча даёт неверный ответ, если про него забыть: CHECK-констрейнт схемы,
# IN-списки обоих сторожей (_alert_if_consecutive_failures / _zero_results),
@@ -570,12 +570,21 @@ def mark_skipped(db: Session, *, source: str, reason: str, details: str | None =
return int(row.id)
-def update_heartbeat(db: Session, run_id: int, counters: dict[str, int]) -> None:
- """UPDATE heartbeat_at + counters=:counters + total_seen/new_count колонки.
+def update_heartbeat(db: Session, run_id: int, counters: dict[str, Any]) -> None:
+ """UPDATE heartbeat_at + counters (МЕРЖ, не замена) + total_seen/new_count колонки.
total_seen/new_count извлекаются из counters (lots_fetched/lots_inserted) и
пишутся в выделенные колонки, чтобы observability не показывала 0 (audit #1926).
COALESCE: если ключа нет в counters — старое значение колонки сохраняется.
+
+ `counters || :counters` вместо замены (#930 добивка): чекпоинт `done_buckets`
+ писали ТОЛЬКО сайты, знающие о нём (`_on_bucket`), а heartbeat'ы, которые о нём не
+ знают, целиком перезаписывали объект и СТИРАЛИ точку. На проде это давало
+ немонотонную точку: `_on_progress` (после каждой комнатности) и фоновый heartbeat
+ cian'а (каждые 60 с) отправляли `counters.to_dict()` без ключа — то есть у cian
+ точка в БД жила лишь от сохранения бакета до ближайшего тика. Прод-след: 15
+ оборванных прогонов с доказанной работой (35 706 + 9 222 fetched) и БЕЗ ключа
+ вообще. Мерж делает точку монотонной для любого писателя, а не только для знающих.
"""
total_seen, new_count = _column_counts(counters)
db.execute(
@@ -583,7 +592,7 @@ def update_heartbeat(db: Session, run_id: int, counters: dict[str, int]) -> None
"""
UPDATE scrape_runs
SET heartbeat_at = clock_timestamp(),
- counters = CAST(:counters AS jsonb),
+ counters = COALESCE(counters, CAST('{}' AS jsonb)) || CAST(:counters AS jsonb),
total_seen = COALESCE(CAST(:total_seen AS int), total_seen),
new_count = COALESCE(CAST(:new_count AS int), new_count)
WHERE id = :run_id
@@ -641,7 +650,7 @@ def mark_done(db: Session, run_id: int, counters: dict[str, int]) -> None:
UPDATE scrape_runs
SET status = 'done',
finished_at = clock_timestamp(), heartbeat_at = clock_timestamp(),
- counters = CAST(:counters AS jsonb),
+ counters = COALESCE(counters, CAST('{}' AS jsonb)) || CAST(:counters AS jsonb),
total_seen = COALESCE(CAST(:total_seen AS int), total_seen),
new_count = COALESCE(CAST(:new_count AS int), new_count)
WHERE id = :run_id AND status = 'running'
@@ -681,7 +690,8 @@ def mark_failed(db: Session, run_id: int, error: str, counters: dict[str, int])
UPDATE scrape_runs
SET status = 'failed',
finished_at = clock_timestamp(), heartbeat_at = clock_timestamp(),
- error = :error, counters = CAST(:counters AS jsonb),
+ error = :error,
+ counters = COALESCE(counters, CAST('{}' AS jsonb)) || CAST(:counters AS jsonb),
total_seen = COALESCE(CAST(:total_seen AS int), total_seen),
new_count = COALESCE(CAST(:new_count AS int), new_count)
WHERE id = :run_id AND status = 'running'
@@ -715,8 +725,9 @@ def mark_banned(
Per migration 015 — 'banned' задокументирован как 'Avito вернул 403/captcha'.
Отличается от 'failed': прогон оборван внешним/блокирующим условием, а не нашим
- багом, и — важно — СОХРАНЯЕТ done_buckets-чекпоинт в counters (mark_failed его
- теряет). Cooldown 2-4 часа.
+ багом. Чекпоинт done_buckets раньше сохранял только этот финализатор — теперь
+ counters мержатся во всех (см. update_heartbeat), и точка переживает любой из них.
+ Cooldown 2-4 часа.
`ban_kind` разводит два исхода, которые раньше схлопывались в один статус:
- BAN_KIND_PLATFORM — площадка нас заблокировала (firewall/403/captcha);
@@ -742,7 +753,8 @@ def mark_banned(
UPDATE scrape_runs
SET status = 'banned',
finished_at = clock_timestamp(), heartbeat_at = clock_timestamp(),
- error = :error, counters = CAST(:counters AS jsonb),
+ error = :error,
+ counters = COALESCE(counters, CAST('{}' AS jsonb)) || CAST(:counters AS jsonb),
ban_kind = :ban_kind,
total_seen = COALESCE(CAST(:total_seen AS int), total_seen),
new_count = COALESCE(CAST(:new_count AS int), new_count)
diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py
index 06d778a8..8b0b5009 100644
--- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py
+++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py
@@ -497,6 +497,132 @@ def _claim_run(db: Session, schedule_row: dict[str, Any], ctx: SchedulerContext)
return run_id
+# ── подхват чекпоинта оборванного прогона (#930, вторая половина) ────────────
+# #930 сделал чекпоинт (`counters.done_buckets`) и приёмную сторону
+# (`run_*_full_load(resume_run_id=...)`), но единственным входом оставил админку. У
+# avito full-load её нет вовсе, поэтому 299 из 433 «впустую перебранных» корзин за 90
+# суток не имели НИКАКОГО пути возобновления, даже ручного. Планировщик передавал
+# resume_run_id=None литералом.
+#
+# Точка берётся только когда выполнены ВСЕ условия ниже; иначе прогон честно начинает с
+# нуля, а ПРИЧИНА пишется в его counters (молчаливый отказ неотличим от отсутствия
+# правки — см. _resume_decision).
+
+# 'zombie' НЕ в списке НАМЕРЕННО. reap_zombies снимает пометку 'running', но НЕ убивает
+# процесс (прямо задокументировано в app/tasks/listing_source_snapshot.py) — а
+# has_running_run гейтит claim именно по статусу. То есть после reap'а старый сборщик
+# может продолжать писать в ту же строку: подхват читал бы ДВИЖУЩУЮСЯ точку и запускал
+# второй сборщик на ту же площадку. 147 корзин в 10 zombie-прогонах за 90 суток
+# остаются несобранными сознательно — это цена, а не недосмотр.
+# 'failed' в списке: его чекпоинт больше не стирается финализатором (runs.py, мерж
+# counters), а причина отказа («наш баг») ничего не говорит о полноте УЖЕ записанных
+# корзин — они записаны тем же heartbeat'ом, что и у banned.
+_RESUME_STATUSES = frozenset({"banned", "cancelled", "failed"})
+
+# Длина цепочки возобновлений. Не круглое число: STALE_DIGEST_INTERVAL_FACTOR (=3) —
+# уже существующий в этом файле порог «источник не собирал дольше 3× своего такта =
+# сломан». Цепочка не имеет права отодвинуть полный обход дальше этой же черты,
+# поэтому подряд идущих подхватов допускается на один меньше: прогоны 1 и 2 могут
+# продолжать предшественника, третий обязан пойти с нуля. При такте avito 7 суток это
+# гарантирует попытку полного обхода не реже, чем раз в 21 сутки — ровно в тот момент,
+# когда сводка объявляет источник просроченным.
+_MAX_RESUME_CHAIN = STALE_DIGEST_INTERVAL_FACTOR - 1
+
+# Кандидат — ПОСЛЕДНИЙ прогон источника, а не последний подходящий: если после обрыва
+# уже прошёл полный ('done') прогон, дерево обойдено и возобновлять нечего. Строки
+# 'skipped' — бухгалтерия планировщика, а не прогоны, поэтому не в счёт.
+_RESUME_CANDIDATE_SQL = text("""
+ WITH cur AS (
+ SELECT source, params FROM scrape_runs WHERE id = CAST(:rid AS bigint)
+ )
+ SELECT r.id AS prev_id,
+ r.status AS prev_status,
+ r.counters AS prev_counters,
+ (r.params IS NOT DISTINCT FROM cur.params) AS same_params,
+ EXTRACT(EPOCH FROM (clock_timestamp() - r.heartbeat_at)) / 3600.0 AS age_h,
+ cur.params ->> 'interval_days' AS interval_days
+ FROM scrape_runs r, cur
+ WHERE r.source = cur.source
+ AND r.id <> CAST(:rid AS bigint)
+ AND r.status <> 'skipped'
+ ORDER BY r.started_at DESC
+ LIMIT 1
+""")
+
+
+def _resume_decision(row: Any) -> tuple[int | None, dict[str, Any]]:
+ """Решение «подхватывать ли точку» + счётчики-объяснение. Чистая функция.
+
+ Возвращает (resume_run_id | None, counters-заготовка нового прогона). Причина
+ отказа — машиночитаемый слаг в `resume_reason`, по нему «предыдущего прогона не
+ было» отличается от «параметры разъехались» ЗАПРОСОМ, а не грепом логов.
+
+ Срок годности точки — такт источника ПЛЮС сутки (`interval_days` из его же params).
+ Ни одно из слагаемых не выбрано произвольно. Такт — объявленный самим расписанием
+ срок, в течение которого собранное считается свежим; точка старше него пережила
+ цикл, в котором источник обязан был обойти дерево целиком. Сутки — сетка запуска:
+ `compute_next_run_at` выбирает ДЕНЬ (сегодня+interval_days) и случайное время внутри
+ окна, поэтому два соседних запуска отстоят друг от друга на interval_days ± меньше
+ суток. Без этого слагаемого точку отвергал бы jitter расписания, а не устаревание:
+ прогон 3547 убит деплоем 09.08 16:53, расписание 139 подхватит его 16.08 13:37 —
+ 164.6 ч при такте 168 ч, запас 3.4 ч при ширине окна 2 ч. Пропущенный цикл в окно
+ всё равно не влезает: для avito это 13 суток против порога 8.
+ """
+ if row is None:
+ return None, {"resume_from": None, "resume_reason": "no_prev_run", "resume_chain": 0}
+
+ prev_counters = row.prev_counters if isinstance(row.prev_counters, dict) else {}
+ done_buckets = prev_counters.get("done_buckets")
+ done_n = len(done_buckets) if isinstance(done_buckets, list) else 0
+ chain_raw = prev_counters.get("resume_chain")
+ prev_chain = chain_raw if isinstance(chain_raw, int) else 0
+ verdict: dict[str, Any] = {
+ "resume_from": None,
+ "resume_candidate": int(row.prev_id),
+ "resume_buckets": done_n,
+ "resume_chain": 0,
+ }
+
+ if row.prev_status not in _RESUME_STATUSES:
+ verdict["resume_reason"] = f"status_{row.prev_status}"
+ elif not row.same_params:
+ verdict["resume_reason"] = "params_changed"
+ elif done_n == 0:
+ verdict["resume_reason"] = "no_checkpoint"
+ elif row.age_h is None or float(row.age_h) > 24.0 * (
+ _schedule_interval_days(row.interval_days) + 1
+ ):
+ verdict["resume_reason"] = "checkpoint_stale"
+ elif prev_chain >= _MAX_RESUME_CHAIN:
+ verdict["resume_reason"] = "chain_limit"
+ else:
+ verdict["resume_from"] = int(row.prev_id)
+ verdict["resume_reason"] = "ok"
+ verdict["resume_chain"] = prev_chain + 1
+ return int(row.prev_id), verdict
+ return None, verdict
+
+
+def _pick_resume(db: Session, run_id: int) -> int | None:
+ """Чекпоинт какого прогона наследует `run_id` (или None) + запись вердикта.
+
+ Тождество задания сверяется РОВНО по тем полям, которыми задание задаётся: source
+ (кандидат ищется в пределах одного source) и params целиком, побайтово. Кандидат
+ сравнивается с ТЕКУЩИМ прогоном, а не с расписанием, потому что именно params
+ прогона поехали в pipeline. На проде за 90 суток 89 корзин из 433 (21%) лежат в
+ прогонах, чьи params отличаются от следующего — по ним пропуск был бы неверным:
+ `incremental_days` меняет СМЫСЛ ключа (дочитано до watermark ≠ бакет перебран), а
+ `price_cap_per_bucket` меняет само дерево бисекции, то есть какие ключи существуют.
+ """
+ row = db.execute(_RESUME_CANDIDATE_SQL, {"rid": run_id}).fetchone()
+ resume_run_id, verdict = _resume_decision(row)
+ # Вердикт кладём в counters НОВОГО прогона: `update_heartbeat` мержит jsonb, поэтому
+ # последующие heartbeat'ы пайплайна его не затрут и он доживёт до финализатора.
+ _kit_runs.update_heartbeat(db, run_id, verdict)
+ logger.info("scheduler: resume run_id=%d — %s", run_id, verdict)
+ return resume_run_id
+
+
def _defer_next_run_at(db: Session, schedule_row: dict[str, Any]) -> None:
"""Сдвинуть next_run_at на следующее окно БЕЗ создания run (#1522).
@@ -705,7 +831,7 @@ async def _job_avito_full_load(
concurrency=int(params.get("concurrency", 5)),
request_delay_sec=float(params.get("request_delay_sec", 7.0)),
secondary_only=bool(params.get("secondary_only", True)),
- resume_run_id=None,
+ resume_run_id=_pick_resume(db, run_id),
incremental_days=incremental_days,
)
@@ -725,7 +851,7 @@ async def _job_avito_full_load_exhaustive(
concurrency=int(params.get("concurrency", 5)),
request_delay_sec=float(params.get("request_delay_sec", 7.0)),
secondary_only=bool(params.get("secondary_only", True)),
- resume_run_id=None,
+ resume_run_id=_pick_resume(db, run_id),
incremental_days=None,
)
@@ -796,7 +922,7 @@ async def _job_cian_full_load(
request_delay_sec=float(params.get("request_delay_sec", 4.0)),
enrich_detail=bool(params.get("enrich_detail", False)),
detail_top_n=int(params.get("detail_top_n", 0)),
- resume_run_id=None,
+ resume_run_id=_pick_resume(db, run_id),
)
diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py
index 3c3d717a..95e2618a 100644
--- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py
+++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py
@@ -1095,7 +1095,9 @@ class AvitoScraper(BaseScraper):
leaf-бакета. Может быть async или sync. Исключение прерывает прогон.
on_progress: опциональный callback(unique_count) для heartbeat (per room-bucket).
skip_buckets: множество ключей «room_label:lo:hi» уже завершённых бакетов —
- пагинация и on_bucket для них пропускаются. Probe-запросы выполняются.
+ пагинация и on_bucket для них пропускаются. В exhaustive-режиме probe-запросы
+ всё равно выполняются (skip проверяется уже в листе, после probe), в
+ инкрементальном probe'а нет — там пропускается весь бакет целиком.
since: если None (default) — EXHAUSTIVE bisection-обход (поведение без
изменений). Если задана date — INCREMENTAL: на каждый (комнатность ×
seed-брекет) последовательная пагинация newest-first с ранней остановкой,
@@ -1141,6 +1143,7 @@ class AvitoScraper(BaseScraper):
max_pages_per_bucket=max_pages_per_bucket,
secondary_only=secondary_only,
on_bucket=on_bucket,
+ skip_buckets=skip_buckets,
)
else:
await self._walk_price_range(
@@ -1390,14 +1393,17 @@ class AvitoScraper(BaseScraper):
# иначе фетчим её как обычную страницу (открытый брекет без probe-html).
sem = asyncio.Semaphore(concurrency)
first_url = self._build_rooms_url(room_slug, 1, _lo_param, _hi_param)
+ dropped_pages = 0 # страницы, не отдавшие карточки по отказу (не по пустоте)
async def _one_page(p: int) -> list[ScrapedLot]:
+ nonlocal dropped_pages
if p == 1 and html is not None:
return self._parse_html(html, source_url_base=first_url)
async with sem:
page_html = await self._fetch_rooms_page_html(room_slug, p, _lo_param, _hi_param)
await asyncio.sleep(self.request_delay_sec)
if page_html is None:
+ dropped_pages += 1
logger.warning(
"avito: page_html=None %s [%d, %s] page=%d — skipping page",
room_label,
@@ -1420,6 +1426,7 @@ class AvitoScraper(BaseScraper):
# Блокировки пробрасываем наверх (mark_banned в pipeline-обёртке).
if isinstance(res, AvitoBlockedError | AvitoRateLimitedError):
raise res
+ dropped_pages += 1
logger.warning(
"avito: page exception %s [%d, %s] page=%d — %r",
room_label,
@@ -1464,8 +1471,25 @@ class AvitoScraper(BaseScraper):
if key:
seen[key] = lot
+ # ── Полнота бакета: ключ у целиком и частично собранного ОДИНАКОВ ─────
+ # `bucket_key` — это "room:lo:hi" и больше ничего, поэтому «сделано» едет
+ # отдельным аргументом. Три пути частичности сходятся здесь:
+ # 1. страница молча выпала (page_html=None выше);
+ # 2. исключение страницы проглочено gather'ом (return_exceptions=True);
+ # 3. признанный tail-loss — probe провалился (expected_total=None, открытый
+ # брекет) или страниц нужно больше, чем max_pages.
+ # Ни один из них не оставлял следа в чекпоинте: следующий прогон видел ключ и
+ # пропускал бакет. Пока признак не доехал до done_buckets, включать подхват
+ # нельзя — видимая потеря (перескрап) стала бы невидимой (пропуск страниц).
+ pages_needed = (
+ math.ceil(expected_total / _AVITO_OFFERS_PER_PAGE)
+ if expected_total is not None
+ else None
+ )
+ complete = dropped_pages == 0 and pages_needed is not None and pages_needed <= max_pages
logger.info(
- "avito: %s [%d, %s] paginated=%d pages collected=%d dropped_nb=%d unique_total=%d",
+ "avito: %s [%d, %s] paginated=%d pages collected=%d dropped_nb=%d unique_total=%d "
+ "complete=%s dropped_pages=%d",
room_label,
lo,
_hi_repr,
@@ -1473,11 +1497,13 @@ class AvitoScraper(BaseScraper):
collected_this_bucket,
dropped_nb,
len(seen),
+ complete,
+ dropped_pages,
)
# ── on_bucket callback: инкрементальный save ──────────────────────────
if on_bucket is not None and bucket_lots:
- res_cb = on_bucket(bucket_key, bucket_lots)
+ res_cb = on_bucket(bucket_key, bucket_lots, complete)
if inspect.isawaitable(res_cb):
await res_cb
@@ -1493,6 +1519,7 @@ class AvitoScraper(BaseScraper):
max_pages_per_bucket: int,
secondary_only: bool,
on_bucket: Callable[..., Any] | None,
+ skip_buckets: set[str] | None = None,
) -> None:
"""INCREMENTAL пагинация одного (комнатность × seed-брекет) с ранней остановкой.
@@ -1514,16 +1541,37 @@ class AvitoScraper(BaseScraper):
bucket_key, secondary_only-фильтр и дедуп в seen — идентичны _paginate_leaf_bucket.
on_bucket вызывается один раз для собранного бакета (async/sync-aware).
AvitoBlockedError/AvitoRateLimitedError из page-фетчей пробрасываются наверх.
+
+ skip_buckets: ключи, дочитанные ПРЕДЫДУЩИМ прогоном с ТЕМИ ЖЕ params (тождество
+ задания проверяет планировщик, `_pick_resume`). В инкрементальном режиме
+ «сделано» значит «дочитал до watermark `since`», а не «перебрал бакет целиком»,
+ поэтому смешивать такой ключ с exhaustive-ключом нельзя — они дословно совпадают
+ (6 из 11 seed-ключей), но означают разное. Пропуск здесь безопасен по покрытию
+ ровно потому, что окно ретроспективы не уже такта (#2674, гарантируется
+ _job_avito_full_load): бакет, дочитанный до watermark N суток назад, следующий
+ плановый прогон перечитает со своим since = сегодня−N и увидит всё, что успело
+ появиться. Раньше аргумент сюда не передавался вовсе — resume в боевом
+ (инкрементальном) режиме avito_full_load был чистым no-op: 134 из 242 корзин.
+
+ complete=False (см. `_paginate_leaf_bucket`) отдаётся, когда бакет НЕ дочитан до
+ watermark: страница выпала, или страниц не хватило (max_pages), или остановка
+ произошла по grace-эвристике «2 подряд недатированные страницы» — там watermark
+ не доказан, а не достигнут.
"""
_lo_param = lo if lo > 0 else None
_hi_param = hi # None → _build_rooms_url не ставит pmax
_hi_repr = "open" if hi is None else str(hi)
bucket_key = f"{room_label}:{lo}:{_hi_repr}"
+ if skip_buckets and bucket_key in skip_buckets:
+ logger.info("avito: skip bucket %s — already read to watermark (resume)", bucket_key)
+ return
+
bucket_lots: list[ScrapedLot] = []
pages_fetched = 0
not_fresh_streak = 0 # подряд идущие не-свежие (all None-или-старые) страницы
stop_reason = "max-pages" # перетирается ниже на реальную причину
+ complete = False # дочитан ли бакет до watermark; True только на честных стопах
for p in range(1, max_pages_per_bucket + 1):
# Последовательный фетч (НЕ asyncio.gather): early-stop требует читать
@@ -1540,6 +1588,7 @@ class AvitoScraper(BaseScraper):
page_lots = self._parse_html(page_html, source_url_base=page_url)
if not page_lots:
stop_reason = "end-of-pages (empty parse)"
+ complete = True # выдача кончилась — читать в этом брекете больше нечего
break
bucket_lots.extend(page_lots)
@@ -1556,6 +1605,7 @@ class AvitoScraper(BaseScraper):
# Есть даты, но все < since → newest-first гарантирует, что дальше
# только старее → стоп немедленно.
stop_reason = "early-stop (page all older than since)"
+ complete = True # watermark достигнут — ровно то, что значит «сделано»
break
# Все карточки undated (None) → не стопим сразу (могут быть свежие без
# даты), но копим streak; 2 подряд недатированные страницы → grace-стоп.
@@ -1581,7 +1631,7 @@ class AvitoScraper(BaseScraper):
logger.info(
"avito: %s [%d, %s] paginated=%d pages collected=%d dropped_nb=%d "
- "unique_total=%d incremental since=%s stop=%s",
+ "unique_total=%d incremental since=%s stop=%s complete=%s",
room_label,
lo,
_hi_repr,
@@ -1591,11 +1641,12 @@ class AvitoScraper(BaseScraper):
len(seen),
since.isoformat(),
stop_reason,
+ complete,
)
# ── on_bucket callback: инкрементальный save ──────────────────────────
if on_bucket is not None and bucket_lots:
- res_cb = on_bucket(bucket_key, bucket_lots)
+ res_cb = on_bucket(bucket_key, bucket_lots, complete)
if inspect.isawaitable(res_cb):
await res_cb
diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/serp.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/serp.py
index e046ef67..67b5c302 100644
--- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/serp.py
+++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/serp.py
@@ -621,8 +621,10 @@ class CianScraper(BaseScraper):
# Страница 1 уже есть (html из probe выше); остальные — параллельно.
sem = asyncio.Semaphore(concurrency)
+ dropped_pages = 0 if html else 1 # пустой probe-html = страница 1 не собрана
async def _one_page(p: int) -> list[ScrapedLot]:
+ nonlocal dropped_pages
if p == 1:
# Используем уже полученный HTML от probe
return self._parse_serp_html(html) if html else []
@@ -630,6 +632,7 @@ class CianScraper(BaseScraper):
page_html = await self._fetch_page_html(rooms, p, _lo_param, hi)
await asyncio.sleep(self.request_delay_sec)
if page_html is None:
+ dropped_pages += 1
logger.warning(
"cian: page_html=None %s [%d, %s] page=%d — skipping page",
room_label,
@@ -648,6 +651,7 @@ class CianScraper(BaseScraper):
bucket_lots: list[ScrapedLot] = []
for p_idx, res in enumerate(page_results, start=1):
if isinstance(res, BaseException):
+ dropped_pages += 1
logger.warning(
"cian: page exception %s [%d, %s] page=%d — %r",
room_label,
@@ -674,8 +678,16 @@ class CianScraper(BaseScraper):
if key:
seen[key] = lot
+ # ── Полнота бакета: ключ у целиком и частично собранного ОДИНАКОВ ─────
+ # См. avito/serp.py — те же два пути частичности (выпавшая страница,
+ # проглоченное gather'ом исключение) плюс признанный hard-cap выше. У cian это
+ # тяжелее: в SERP-слое НЕТ класса блок-исключения вообще (ban-детект #2625
+ # агрегатный, на выходе из скрапера), поэтому капча посреди бакета приходит сюда
+ # как page_html=None и раньше давала «сделанный» бакет из уцелевших страниц.
+ complete = dropped_pages == 0 and pages_needed <= max_pages
logger.info(
- "cian: %s [%d, %s] paginated=%d pages collected=%d dropped_nb=%d unique_total=%d",
+ "cian: %s [%d, %s] paginated=%d pages collected=%d dropped_nb=%d unique_total=%d "
+ "complete=%s dropped_pages=%d",
room_label,
lo,
_hi_repr,
@@ -683,11 +695,13 @@ class CianScraper(BaseScraper):
collected_this_bucket,
dropped_nb,
len(seen),
+ complete,
+ dropped_pages,
)
# ── on_bucket callback: инкрементальный save ──────────────────────────
if on_bucket is not None and bucket_lots:
- res_cb = on_bucket(bucket_key, bucket_lots)
+ res_cb = on_bucket(bucket_key, bucket_lots, complete)
if inspect.isawaitable(res_cb):
await res_cb
diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/valuation.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/valuation.py
index 0c8c883c..589be38e 100644
--- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/valuation.py
+++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/valuation.py
@@ -62,7 +62,11 @@ class ValuationHouseMeta(BaseModel):
ceiling_height: float | None = None # in meters, e.g. 2.5
has_lift: bool | None = None
total_objects: int | None = None # 'N объектов' (full archive count)
- has_panorama: bool = False # 'Панорама' label present
+ # #2674 (хвост, мигр. 259): признак «панорама» снят с разбора. Слова «панорам» в
+ # любом падеже на странице оценки нет вовсе — проверено боевым трактом 13.08.2026
+ # на трёх адресах ЕКБ (полный HTML ~1.19 МБ, вхождений 0/0/0), при том что мета
+ # разбиралась целиком (год/этажность/материал/потолки/объекты). Признак не
+ # «переехал» — его в предметной области нет; на проде 0 true из 1536 страниц.
def validate_match(
self,
@@ -331,7 +335,6 @@ class YandexValuationScraper(BaseScraper):
ceiling_height=(float(ceiling_m.group(1).replace(",", ".")) if ceiling_m else None),
has_lift="Лифт" in body_text,
total_objects=int(objects_m.group(1)) if objects_m else None,
- has_panorama="Панорама" in body_text,
)
def _parse_history_items(self, tree: HTMLParser, body_text: str) -> list[ValuationHistoryItem]: