From 59482cae85696ef4c939b3487031e90d4f37f56a Mon Sep 17 00:00:00 2001
From: bot-backend
Date: Sat, 12 Sep 2026 20:05:19 +0500
Subject: [PATCH 1/2] =?UTF-8?q?fix(mera):=20=D0=B2=D0=B8=D1=82=D1=80=D0=B8?=
=?UTF-8?q?=D0=BD=D0=B0=20=D1=81=D0=B4=D0=B5=D0=BB=D0=BE=D0=BA=20=D0=B2=20?=
=?UTF-8?q?=D1=80=D0=B0=D1=81=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8=D0=B8,?=
=?UTF-8?q?=20=D0=B4=D0=B0=D1=82=D0=B0=20=D0=BF=D1=80=D0=BE=D0=B3=D0=BE?=
=?UTF-8?q?=D0=BD=D0=B0=20=D0=BD=D0=B0=20=D1=81=D1=82=D1=80=D0=B0=D0=BD?=
=?UTF-8?q?=D0=B8=D1=86=D0=B5=20(#3469)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Задача landing_showcase_deals не запускалась вообще: строки в
scrape_schedules не было (0 строк по '%showcase%' на проде 12.09), а в
реестре product_handlers — обработчика. Пересчёт был ручным шагом, и
лэндинг показывал прогон от 30.08 — тринадцать суток.
Что сделано:
- Handler `landing_showcase_deals` в product_handlers: тело задачи
писалось под `python -m` и про run_id не знает, поэтому done/failed
ставит обработчик (как у refresh_search_matview).
- Миграция 303 сеет расписание: enabled=true, окно 06:00–07:00 UTC,
interval_days=1. Такт суточный не из-за данных — сделки Росреестра
квартальные, — а из-за кода: прогноз считает тот же спайн оценщика,
что и боевой расчёт, и любой деплой меняет числа на витрине, не
трогая ни одной сделки.
- Эта же строка заводит витрину в СУЩЕСТВУЮЩИЙ монитор свежести:
сводка просроченных источников (emit_stale_digest, #2670) ходит по
включённым расписаниям и бьёт ERROR → GlitchTip, когда источник
молчит дольше 3× своего такта. Своего монитора не заводим: витрина
была невидима не потому, что сводка не умеет про неё говорить, а
потому, что источника для сводки не существовало.
- На странице под таблицей — дата прогона рядом со счётчиками:
«Витрина пересчитана 30.08.2026». computed_at ручка /showcase отдавала
и раньше, фронт его не показывал; даты нет — предложения нет.
Тесты: обработчик резолвится тем же resolve_handler, что и боевой
_dispatch; миграция на живой БД реально кладёт строку и не задваивает
её при повторе; настоящий запрос сводки видит витрину и отдаёт её
просроченной после 3× такта (число тактов — литерал из приёмки, не
константа кита: взятое из неё ожидание уезжало вместе с ней и держало
тесты зелёными при факторе 3650).
Closes #3469
Co-Authored-By: Claude Opus 5
---
.../backend/app/services/product_handlers.py | 29 ++
..._schedules_seed_landing_showcase_deals.sql | 79 +++++
tradein-mvp/backend/tests/skip_allowlist.txt | 1 +
.../tests/test_3469_showcase_schedule.py | 286 ++++++++++++++++++
.../__tests__/landing-v3-render.test.tsx | 37 ++-
.../mera-public/_components/v3/AccuracyV3.tsx | 29 +-
.../mera-public/_components/v3/deal-view.ts | 18 ++
7 files changed, 471 insertions(+), 8 deletions(-)
create mode 100644 tradein-mvp/backend/data/sql/303_scrape_schedules_seed_landing_showcase_deals.sql
create mode 100644 tradein-mvp/backend/tests/test_3469_showcase_schedule.py
diff --git a/tradein-mvp/backend/app/services/product_handlers.py b/tradein-mvp/backend/app/services/product_handlers.py
index d2b3c9e3..4dd3e380 100644
--- a/tradein-mvp/backend/app/services/product_handlers.py
+++ b/tradein-mvp/backend/app/services/product_handlers.py
@@ -325,6 +325,34 @@ async def _job_landing_stats(
await loop.run_in_executor(None, refresh_landing_stats, db, run_id, params)
+# ── landing_showcase_deals — sync пересчёт витрины сделок в executor ─────────
+async def _job_landing_showcase_deals(
+ db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
+) -> None:
+ """Пересчёт витрины сделок публичного лэндинга (#3469).
+
+ ЛАЙФСАЙКЛ ПРОГОНА ВЕДЁТ HANDLER, а не задача. `refresh_landing_showcase_deals`
+ писалась под ручной запуск (`python -m app.tasks.landing_showcase_deals`) и про
+ `run_id` ничего не знает — тот же случай, что у `_job_refresh_search_matview`,
+ и решается так же: done/failed ставим здесь.
+
+ Параметры берём ИЗ РАСПИСАНИЯ только те, что в нём есть: дефолты живут в
+ сигнатуре задачи, и повтор их здесь дал бы два места, которые разъедутся.
+ """
+ from app.tasks.landing_showcase_deals import refresh_landing_showcase_deals
+
+ kwargs = {k: params[k] for k in ("sample", "since", "limit", "city") if k in params}
+ loop = asyncio.get_event_loop()
+ try:
+ counters = await loop.run_in_executor(
+ None, lambda: refresh_landing_showcase_deals(db, **kwargs)
+ )
+ ctx.runs.mark_done(db, run_id, counters)
+ except Exception:
+ logger.exception("scheduler: landing_showcase_deals crashed run_id=%d", run_id)
+ ctx.runs.mark_failed(db, run_id, "landing_showcase_deals failed", {})
+
+
# ── sber_freshness_monitor — sync DB-only freshness check в executor ──────────
async def _job_sber_freshness_monitor(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
@@ -911,6 +939,7 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
"deals_freshness_monitor": Handler(_job_deals_freshness_monitor, "deals_freshness_monitor"),
"sber_freshness_monitor": Handler(_job_sber_freshness_monitor, "sber_freshness_monitor"),
"landing_stats_refresh": Handler(_job_landing_stats, "landing_stats_refresh"),
+ "landing_showcase_deals": Handler(_job_landing_showcase_deals, "landing_showcase_deals"),
"newbuilding_enrich": Handler(_job_newbuilding_enrich, "newbuilding_enrich"),
"yandex_newbuilding_sweep": Handler(
_job_yandex_newbuilding_sweep, "yandex_newbuilding_sweep"
diff --git a/tradein-mvp/backend/data/sql/303_scrape_schedules_seed_landing_showcase_deals.sql b/tradein-mvp/backend/data/sql/303_scrape_schedules_seed_landing_showcase_deals.sql
new file mode 100644
index 00000000..7b5d6ed7
--- /dev/null
+++ b/tradein-mvp/backend/data/sql/303_scrape_schedules_seed_landing_showcase_deals.sql
@@ -0,0 +1,79 @@
+-- 303_scrape_schedules_seed_landing_showcase_deals.sql
+-- Расписание для пересчёта витрины сделок публичного лэндинга (issue #3469).
+--
+-- ЧТО БЫЛО. Задача `landing_showcase_deals` (миграции 276/277, таблицы
+-- landing_showcase_deals + landing_showcase_runs) в scrape_schedules НЕ СТОЯЛА:
+-- `SELECT * FROM scrape_schedules WHERE source LIKE '%showcase%'` — 0 строк
+-- (замер на проде 12.09.2026). Пересчёт был ручным шагом, и за всё время его
+-- запускали четырежды; на 12.09 лэндинг показывал прогон от 30.08 — тринадцать
+-- суток. Handler в реестре тоже отсутствовал, то есть строка расписания без
+-- него не помогла бы: обе половины регистрации задачи (Handler в
+-- app/services/product_handlers.py + вот эта строка) едут одним PR.
+--
+-- ТАКТ — СУТКИ, И СЧИТАЕТСЯ ОН НЕ ОТ ДАННЫХ, А ОТ КОДА.
+-- Вход витрины — ДКП-сделки Росреестра, они приезжают ПОКВАРТАЛЬНО, и по
+-- входу хватило бы такта в квартал. Но витрина показывает не сделки, а
+-- РАСХОЖДЕНИЕ прогноза МЕРЫ с ценой сделки, а прогноз пересчитывается тем же
+-- спайном оценщика, что и боевой расчёт: любая правка оценщика, коэффициентов
+-- СберИндекса, набора активных объявлений или правила отбора (миграция 276,
+-- полоса −5..+20 % от 12.09.2026) меняет ЧИСЛА на странице, не трогая ни одной
+-- сделки. Деплой у продукта чаще, чем квартал, — поэтому такт суточный: столько
+-- живёт окно «код уже другой, а витрина ещё прежняя». Прогон дешёвый и без
+-- внешних вызовов (200 сделок через спайн + запись 20 строк, ~минуты CPU
+-- ночью), так что цена суточного такта — та же, что у соседнего
+-- landing_stats_refresh (миграция 275).
+--
+-- ЭТА ЖЕ СТРОКА ЗАВОДИТ ВИТРИНУ В МОНИТОР СВЕЖЕСТИ. Сводка просроченных
+-- источников (`emit_stale_digest`, scraper_kit/orchestration/scheduler.py,
+-- #2670) ходит по ВКЛЮЧЁННЫМ расписаниям и бьёт тревогу (logger.error →
+-- GlitchTip), когда источник не приносил данных дольше
+-- STALE_DIGEST_INTERVAL_FACTOR × его такта — то есть здесь дольше ТРЁХ СУТОК.
+-- Отдельного монитора для витрины не заводится намеренно: её молчание было
+-- невидимо ровно потому, что источника не существовало для сводки, а не потому,
+-- что сводка не умеет про него говорить (живой пример с прода 12.09.2026:
+-- «1 источников не собирают дольше 3× своего такта — avito_newbuilding_sweep
+-- 3.5d/1d»). interval_days в default_params стоит ЯВНО — им же сводка считает
+-- порог (`_schedule_interval_days`), и умолчание «1» лучше не подразумевать.
+--
+-- ОКНО 06:00–07:00 UTC (11:00–12:00 по Екатеринбургу): после импорта сделок
+-- Росреестра (rosreestr_dkp_import, окно 04–06) и после landing_stats_refresh
+-- (05–06) — витрина считается по уже обновлённым за ночь данным; и за два часа
+-- до deals_freshness_monitor (08–09), так что утренний пересчёт успевает
+-- сняться с просрочки до утренней же проверки.
+--
+-- enabled=true — как у landing_stats_refresh: задача только читает базу и
+-- перезаписывает две свои маленькие таблицы, внешних вызовов нет, цена ошибки —
+-- минуты CPU. Дожидаться ручного включения тут значило бы оставить дефект
+-- #3469 на месте, просто под другой причиной.
+--
+-- next_run_at на завтра 06:00 UTC — прогон не выстреливает в момент деплоя
+-- (образец: 162_seed_deals_freshness_monitor.sql, 275_landing_stats.sql).
+--
+-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)), 276/277
+-- (таблицы витрины), Handler 'landing_showcase_deals' в product_handlers.py.
+-- Идемпотентно: ON CONFLICT (source) DO NOTHING.
+
+BEGIN;
+-- Конвенция проекта (#2752): блокирующий DDL/DML под lock_timeout.
+SET LOCAL lock_timeout = '5s';
+
+INSERT INTO scrape_schedules (
+ source,
+ enabled,
+ window_start_hour,
+ window_end_hour,
+ next_run_at,
+ default_params
+)
+VALUES
+(
+ 'landing_showcase_deals',
+ true,
+ 6,
+ 7,
+ ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 6)) AT TIME ZONE 'UTC',
+ '{"interval_days": 1, "sample": 200, "limit": 20}'::jsonb
+)
+ON CONFLICT (source) DO NOTHING;
+
+COMMIT;
diff --git a/tradein-mvp/backend/tests/skip_allowlist.txt b/tradein-mvp/backend/tests/skip_allowlist.txt
index aef1335e..9412996e 100644
--- a/tradein-mvp/backend/tests/skip_allowlist.txt
+++ b/tradein-mvp/backend/tests/skip_allowlist.txt
@@ -38,6 +38,7 @@ tests/test_house_dedup_merge.py::test_real_fias_pass_ignores_geo_guard
tests/test_house_dedup_merge.py::test_real_merge_is_reversible_via_journal
tests/test_house_dedup_merge.py::test_real_merge_repoints_dedups_deletes_and_is_idempotent
tests/test_user_events.py::test_real_record_event_inserts_row
+tests/test_3469_showcase_schedule.py::test_live_migration_puts_showcase_into_schedules_and_digest
# Приватность/ретеншн (#2547) — тот же `_live_session()`. Приехали в main
# параллельно с самим списком, поэтому первым же прогоном deploy-лэйна хук их и
diff --git a/tradein-mvp/backend/tests/test_3469_showcase_schedule.py b/tradein-mvp/backend/tests/test_3469_showcase_schedule.py
new file mode 100644
index 00000000..1f8453c4
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_3469_showcase_schedule.py
@@ -0,0 +1,286 @@
+"""Витрина сделок лэндинга попадает в расписание и в монитор свежести (#3469).
+
+ЧТО БЫЛО СЛОМАНО. `landing_showcase_deals` считает витрину публичного лэндинга
+(«МЕРА сказала X — продали за Y»), но в `scrape_schedules` строки для неё не было
+вовсе (`WHERE source LIKE '%showcase%'` — 0 строк на проде 12.09.2026), а в
+реестре `product_handlers` — обработчика. То есть планировщик про задачу не знал
+ни с какой стороны, пересчёт был ручным, и страница показывала прогон
+тринадцатисуточной давности. Заметить это было неоткуда: под таблицей печатались
+счётчики прогона, но не его дата, а сводка просроченных источников
+(`emit_stale_digest`, #2670) ходит по ВКЛЮЧЁННЫМ РАСПИСАНИЯМ — источника, которого
+в таблице нет, для неё не существует.
+
+ЧТО ПРОВЕРЯЕТСЯ ЗДЕСЬ, И ПОЧЕМУ ИМЕННО ЭТО.
+
+ 1. Обработчик резолвится ТЕМ ЖЕ `resolve_handler`, которым его ищет боевой
+ `_dispatch`. Одной строки расписания мало: без обработчика планировщик
+ нашёл бы задачу и не смог её запустить.
+ 2. Миграция 303 сеет строку, и сеет её ВКЛЮЧЁННОЙ с явным `interval_days` —
+ сводка считает порог просрочки из этого же числа.
+ 3. Сводка краснеет, когда витрина не пересчитывалась дольше ТРЁХ тактов
+ (приёмка #3469), и молчит на двух. Число тактов здесь — литерал, а такт
+ читается из миграции: ожидание, взятое из той же настройки, которую
+ проверяешь, уезжает вместе с ней — см. комментарий при _ACCEPTANCE_CYCLES.
+ 4. Живой Postgres (само-скип): миграция реально вставляет строку в таблицу,
+ повторное применение её не задваивает, и настоящий запрос сводки
+ `_STALE_SOURCES_SQL` видит эту строку и отдаёт витрину просроченной.
+
+ЖИВОЙ ТЕСТ НЕ УДАЛЯЕТ И НЕ ПРАВИТ НИЧЕГО ЧУЖОГО: он применяет те же идемпотентные
+файлы, что применяет деплой (CREATE TABLE IF NOT EXISTS + ON CONFLICT DO NOTHING).
+В CI он ИДЁТ — ci-tradein.yml поднимает свой Postgres и кладёт DSN в DATABASE_URL;
+на машине без базы само-скипается (запись в tests/skip_allowlist.txt). Поднять
+локально:
+
+ docker exec tradein-postgres psql -U tradein -c 'CREATE DATABASE t3469'
+ DATABASE_URL="postgresql+psycopg://tradein:tradein@127.0.0.1:5433/t3469" \
+ uv run python -m pytest tests/test_3469_showcase_schedule.py -q
+"""
+
+from __future__ import annotations
+
+import os
+import re
+from datetime import UTC, datetime, timedelta
+from pathlib import Path
+from types import SimpleNamespace
+from typing import Any
+
+import pytest
+
+os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
+
+from scraper_kit.orchestration import scheduler as sched
+
+from app.services.product_handlers import build_product_handlers
+
+SOURCE = "landing_showcase_deals"
+
+_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql"
+_MIGRATION = _SQL_DIR / "303_scrape_schedules_seed_landing_showcase_deals.sql"
+# Таблицы, без которых строку расписания некуда класть (FK scrape_schedules →
+# scrape_runs), — живой тест применяет их в том же порядке, что и деплой.
+_DEPS = [
+ _SQL_DIR / "015_scrape_runs.sql",
+ # counters jsonb — по нему сводка судит, принёс ли прогон данные.
+ _SQL_DIR / "051_scrape_runs_extend.sql",
+ _SQL_DIR / "052_scrape_schedules.sql",
+]
+
+NOW = datetime(2026, 9, 12, 8, 0, tzinfo=UTC)
+
+
+def _migration_sql() -> str:
+ return _MIGRATION.read_text("utf-8")
+
+
+def _seeded_interval_days() -> int:
+ """Такт из САМОЙ миграции — порог сводки считается из него, не из литерала."""
+ m = re.search(r'"interval_days"\s*:\s*(\d+)', _migration_sql())
+ assert m is not None, "в default_params миграции 303 нет interval_days"
+ return int(m.group(1))
+
+
+# ── 1. Планировщик видит задачу ──────────────────────────────────────────────
+
+
+def test_handler_resolves_for_scheduler() -> None:
+ """`resolve_handler` находит витрину — тем же вызовом, что и боевой _dispatch.
+
+ Ломать так: убрать ключ из реестра в product_handlers — тест покраснеет, а
+ планировщик на проде заклеймил бы прогон и не нашёл, чем его выполнить.
+ """
+ registry = build_product_handlers(ctx=None) # type: ignore[arg-type]
+ handler = sched.resolve_handler(SOURCE, registry)
+ assert handler is not None, f"{SOURCE} не резолвится реестром — задача невидима"
+ assert handler.log_name == SOURCE
+
+
+# ── 2. Миграция сеет строку ──────────────────────────────────────────────────
+
+
+def test_migration_303_exists() -> None:
+ assert _MIGRATION.is_file(), f"missing migration: {_MIGRATION}"
+
+
+def test_migration_303_seeds_source_enabled() -> None:
+ sql = _migration_sql()
+ assert f"'{SOURCE}'" in sql
+ assert "INSERT INTO scrape_schedules" in sql
+ # enabled=true — иначе сводка просроченных источников строку не увидит
+ # (_STALE_SOURCES_SQL: WHERE sch.enabled), и монитор молчал бы как раньше.
+ assert re.search(rf"'{SOURCE}',\s*\n\s*true", sql), "расписание засеяно выключенным"
+
+
+def test_migration_303_is_idempotent_and_transactional() -> None:
+ sql = _migration_sql()
+ assert "ON CONFLICT (source) DO NOTHING" in sql
+ assert "BEGIN;" in sql
+ assert "COMMIT;" in sql
+
+
+def test_migration_303_no_psycopg_trap() -> None:
+ assert not re.search(r":\w+::", _migration_sql())
+
+
+def test_migration_303_interval_days_is_daily() -> None:
+ """Такт суточный: витрина устаревает от КОДА (деплой), а не от квартальных сделок."""
+ assert _seeded_interval_days() == 1
+
+
+# ── 3. Сводка свежести краснеет на молчащей витрине ──────────────────────────
+
+
+def _row(age_days: float, *, status: str | None = "done") -> Any:
+ """Строка `_STALE_SOURCES_SQL`: прогон витрины `age_days` суток назад.
+
+ `status=None` (LEFT JOIN не нашёл прогонов) — витрину не пересчитывали ни разу
+ с момента появления расписания; тогда возраст считается от created_at строки.
+ """
+ finished = None if status is None else NOW - timedelta(days=age_days)
+ return SimpleNamespace(
+ source=SOURCE,
+ interval_days=str(_seeded_interval_days()),
+ created_at=NOW - timedelta(days=age_days),
+ finished_at=finished,
+ status=status,
+ counters={"considered": 200, "eligible": 161, "written": 20},
+ )
+
+
+def _stale_now(rows: list[Any]) -> list[sched.StaleSource]:
+ return sched.stale_sources(sched.freshness_rows(rows), NOW)
+
+
+# Приёмка issue #3469 дословно: «отсутствие прогона дольше 3× такта даёт тревогу».
+# ЧИСЛО ЗДЕСЬ ЛИТЕРАЛ, А НЕ `sched.STALE_DIGEST_INTERVAL_FACTOR`. Взятое из той же
+# настройки, которую проверяем, ожидание уезжает вместе с ней: при факторе 3650
+# ЭТИ ЖЕ тесты оставались зелёными (проверено руками), то есть проверяли ровно
+# ничего. Такт (`interval_days`) при этом читается из миграции — правило «3×»
+# и задано в тактах, а не в сутках.
+_ACCEPTANCE_CYCLES = 3
+
+
+def test_digest_flags_showcase_after_three_cycles() -> None:
+ """Нет пересчёта дольше 3× такта → витрина в сводке просроченных."""
+ lag = _ACCEPTANCE_CYCLES * _seeded_interval_days() + 0.5
+ stale = _stale_now([_row(lag)])
+ assert [s.source for s in stale] == [SOURCE], (
+ f"витрина молчит {lag} суток при такте {_seeded_interval_days()} и не в тревоге"
+ )
+ assert stale[0].interval_days == _seeded_interval_days()
+
+
+def test_digest_silent_within_cycle() -> None:
+ """Контроль: два такта — ещё норма, иначе тревога кричала бы всегда."""
+ lag = 2 * _seeded_interval_days()
+ assert _stale_now([_row(lag)]) == []
+
+
+def test_digest_flags_showcase_that_never_ran() -> None:
+ """Расписание есть, прогонов нет — самый частый вид молчания (#3469 и был им)."""
+ lag = _ACCEPTANCE_CYCLES * _seeded_interval_days() + 1
+ stale = _stale_now([_row(lag, status=None)])
+ assert [s.source for s in stale] == [SOURCE]
+ assert stale[0].never_ok is True
+
+
+def test_showcase_counters_do_not_fake_freshness() -> None:
+ """Свежесть даёт ПРОГОН, а не его счётчики.
+
+ `run_brought_data` судит по результатным ключам kit'а, а у витрины их нет
+ (`considered`/`eligible`/`written` — свой словарь). Значит мерой остаётся
+ успешный статус: прогон, свалившийся в failed, свежести не даёт.
+ """
+ assert sched.run_brought_data("done", {"considered": 200, "written": 20}) is True
+ assert sched.run_brought_data("failed", {"considered": 200, "written": 0}) is False
+
+
+# ── 4. Живая БД: строка реально ложится в таблицу ────────────────────────────
+
+
+def _live_session() -> Any | None:
+ """Session на живой Postgres — та же проба, что у соседних живых тестов.
+
+ `TEST_DATABASE_URL` имеет приоритет; `localhost:5432/test` — заглушка модулей,
+ её не считаем базой. В CI сюда приезжает DSN поднятого в job'е контейнера
+ (ci-tradein.yml), поэтому проверка там ИДЁТ, а не тихо скипается.
+ """
+ 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)
+ with engine.connect() as conn:
+ conn.execute(text("SELECT 1"))
+ return sessionmaker(bind=engine, future=True)()
+ except Exception:
+ return None
+
+
+def _apply(db: Any, path: Path) -> None:
+ """Прогнать файл миграции целиком, одним куском — как `psql -f` на деплое.
+
+ Через ДРАЙВЕРНОЕ соединение, а не `exec_driver_sql`: последний отдаёт текст
+ psycopg вместе с пустым набором параметров, и тот начинает искать в нём
+ плейсхолдеры — любой процент в комментарии миграции («полоса −5..+20 %»)
+ роняет запуск ошибкой про `%`. psql такого разбора не делает, так что это
+ артефакт теста, а не свойство файла.
+ """
+ db.connection().connection.driver_connection.execute(path.read_text("utf-8"))
+ db.commit()
+
+
+@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
+def test_live_migration_puts_showcase_into_schedules_and_digest() -> None:
+ """Миграция кладёт строку в scrape_schedules, и сводка видит витрину просроченной.
+
+ Значение, а не текст файла: применяем 303 на живой базе (дважды — дублей быть
+ не должно), читаем строку обратно и прогоняем настоящий `_STALE_SOURCES_SQL` —
+ тот же запрос, которым сводка судит на проде.
+
+ Ничего не удаляем: обе миграции идемпотентны (CREATE TABLE IF NOT EXISTS /
+ ON CONFLICT DO NOTHING), то есть повтор здесь — ровно то же действие, что и
+ повторный деплой.
+ """
+ from sqlalchemy import text
+
+ db = _live_session()
+ assert db is not None
+ try:
+ for dep in _DEPS:
+ _apply(db, dep)
+ _apply(db, _MIGRATION)
+ _apply(db, _MIGRATION) # идемпотентность: второй прогон не задваивает
+
+ rows = db.execute(
+ text(
+ "SELECT enabled, window_start_hour, window_end_hour, created_at, "
+ " default_params->>'interval_days' AS interval_days "
+ "FROM scrape_schedules WHERE source = :s"
+ ),
+ {"s": SOURCE},
+ ).fetchall()
+ assert len(rows) == 1, f"ожидалась одна строка расписания, получено {len(rows)}"
+ row = rows[0]
+ assert row.enabled is True
+ assert (row.window_start_hour, row.window_end_hour) == (6, 7)
+ assert int(row.interval_days) == _seeded_interval_days()
+
+ # Сводка: прогонов у витрины нет, возраст считается от created_at строки.
+ digest_rows = list(db.execute(sched._STALE_SOURCES_SQL).fetchall())
+ assert SOURCE in {r.source for r in digest_rows}, "сводка не видит витрину"
+
+ overdue_at = row.created_at + timedelta(
+ days=_ACCEPTANCE_CYCLES * _seeded_interval_days() + 0.5
+ )
+ stale = sched.stale_sources(sched.freshness_rows(digest_rows), overdue_at)
+ assert SOURCE in {s.source for s in stale}, "витрина без прогонов не попала в тревогу"
+
+ fresh_at = row.created_at + timedelta(hours=1)
+ fresh = sched.stale_sources(sched.freshness_rows(digest_rows), fresh_at)
+ assert SOURCE not in {s.source for s in fresh}, "тревога сразу после сева — ложная"
+ finally:
+ db.close()
diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx b/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx
index 892374e7..b1405fdf 100644
--- a/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx
+++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx
@@ -97,6 +97,41 @@ describe("витрина лэндинга v3 без данных", () => {
expect(screen.getByText(/рассмотрено сделок: 4 000/)).toBeTruthy();
});
+ /**
+ * ДАТА ПРОГОНА — НА СТРАНИЦЕ, РЯДОМ СО СЧЁТЧИКАМИ (#3469).
+ *
+ * Счётчики («рассмотрено 200, показано 20») не стареют на вид, а строки под
+ * ними стареют: на проде задачи не было в расписании, и витрина тринадцать
+ * суток показывала прогон от 30.08 — неотличимо от вчерашнего. Проверяется
+ * ПО ЗНАЧЕНИЮ: дата берётся из `computed_at` фикстуры, вписать её в разметку
+ * руками и остаться зелёным нельзя — второй кейс отдаёт другую дату.
+ */
+ it("«Точность»: под таблицей стоит дата пересчёта витрины, и она из данных", () => {
+ const { unmount } = render();
+ expect(screen.getByText(/Витрина пересчитана 29\.08\.2026/u)).toBeTruthy();
+ unmount();
+
+ render(
+ ,
+ );
+ expect(screen.getByText(/Витрина пересчитана 12\.09\.2026/u)).toBeTruthy();
+ });
+
+ /**
+ * Даты нет — нет и предложения про неё: то же правило «нет величины — нет
+ * подписи», что у всего блока. «Invalid Date» или «null» на публичной
+ * странице хуже отсутствия даты, а «сегодня» на её месте — враньё.
+ */
+ it("«Точность»: без computed_at подпись не выдумывает дату", () => {
+ render();
+ const note = screen.getByText(/рассмотрено сделок: 4 000/u);
+ expect(note.textContent).not.toMatch(/Витрина пересчитана/u);
+ expect(note.textContent).not.toMatch(/Invalid Date|null|undefined/u);
+ });
+
/**
* Снятая величина не должна остаться на экране «по инерции»: её плитку занял
* свежий замер, и оговорка про уверенность без своей плитки объясняла бы
@@ -136,7 +171,7 @@ describe("витрина лэндинга v3 без данных", () => {
* УТВЕРЖДЕНИЕ ПРО ПОЛОСУ ПРОВЕРЯЕТ САМО СЕБЯ ПО ПОКАЗАННЫМ СТРОКАМ.
*
* Границы стоят в коде фронта, строки приходят из БД от последнего прогона
- * задачи, а задача в расписании не стоит. Между выкатом фронта и пересчётом
+ * задачи, а такт пересчёта — сутки (#3469). Между выкатом фронта и пересчётом
* витрины на странице лежат СТАРЫЕ строки: на проде 12 из 20 вне полосы,
* худшая +75,71 %. Утверждение «показана полоса −5…+20 %» и строка +75,7 %
* под ним — хуже, чем отсутствие утверждения.
diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/AccuracyV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/AccuracyV3.tsx
index 9cf2a043..d5dab114 100644
--- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/AccuracyV3.tsx
+++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/AccuracyV3.tsx
@@ -30,10 +30,11 @@
* обещала бы точность, которой никто не мерил.
*
* УТВЕРЖДЕНИЕ ПРО ПОЛОСУ САМОПРОВЕРЯЕМОЕ. Границы стоят в коде фронта, строки
- * приходят из БД от последнего прогона задачи, а задача в расписании не стоит
- * — её запускают руками. В окне «фронт выкачен, витрина не пересчитана»
- * страница утверждала бы полосу над строками прежнего правила, а под
- * утверждением стояла бы строка +75,7 % (на проде сейчас 12 таких из 20).
+ * приходят из БД от последнего прогона задачи, и прогон отстаёт от выката:
+ * с #3469 задача стоит в расписании (суточный такт), но между деплоем фронта
+ * и ближайшим ночным пересчётом окно всё равно есть. В нём страница утверждала
+ * бы полосу над строками прежнего правила, а под утверждением стояла бы
+ * строка +75,7 % (так на проде и было — 12 таких из 20).
* Поэтому обе подписи спрашивают сами строки (`allWithinBand` в `deal-view`):
* не соответствуют — про полосу не говорим, называем то, что есть, а правило
* прогона и так печатается рядом и приезжает из ТОГО ЖЕ прогона, что строки.
@@ -87,6 +88,7 @@ import {
dealTitle,
errPct,
rub,
+ runDate,
shownSpread,
} from "./deal-view";
@@ -174,6 +176,9 @@ export function AccuracyV3({
const deals = showcase?.deals ?? [];
const showcaseStats = showcase?.stats ?? null;
const spread = shownSpread(deals);
+ // Дата прогона, давшего эти строки. Стоит в той же подписи, что и счётчики:
+ // «рассмотрено 200, показано 20» не стареет на вид, а строки — стареют.
+ const computedOn = runDate(showcase?.computed_at ?? null);
return (
{showcaseStats
- ? `Показано ${count(showcaseStats.written)} строк из ${count(showcaseStats.eligible)} собранных прогоном, рассмотрено сделок: ${count(showcaseStats.considered)}. Район известен у ${count(showcaseStats.with_district)} из показанных. ${showcaseStats.rejection_rule}`
+ ? `${computedOn ? `Витрина пересчитана ${computedOn}. ` : ""}Показано ${count(showcaseStats.written)} строк из ${count(showcaseStats.eligible)} собранных прогоном, рассмотрено сделок: ${count(showcaseStats.considered)}. Район известен у ${count(showcaseStats.with_district)} из показанных. ${showcaseStats.rejection_rule}`
: "Подпись прогона не пришла — из чего отобраны строки, сказать нечем."}
{spread && (
// УТВЕРЖДЕНИЕ ПРО ПОЛОСУ — САМОПРОВЕРЯЕМОЕ. Границы стоят в коде
// фронта, а строки приходят из БД, из последнего прогона задачи;
- // задача в расписании не стоит и запускается руками. Между
- // выкатом и пересчётом страница утверждала бы полосу над
+ // такт пересчёта — сутки (#3469), то есть строки старше выката
+ // фронта почти всегда. Между выкатом и пересчётом страница
+ // утверждала бы полосу над
// строками, собранными до неё (на проде сейчас 12 из 20 вне
// полосы, худшая +75,7 %) — утверждение и его опровержение в
// одном экране. Поэтому про полосу говорим, только если ни одна
diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts b/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts
index 5bd0b185..98afe3dd 100644
--- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts
+++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts
@@ -29,6 +29,24 @@ export const count = (value: number): string => RUB.format(value);
/** Отклонение прогноза от факта — со знаком: плюс = МЕРА назвала дороже. */
export const errPct = (value: number): string => `${PCT.format(value)} %`;
+/**
+ * Дата пересчёта витрины — «30.08.2026» из `computed_at`.
+ *
+ * РАЗБОР СТРОКИ, А НЕ `new Date().toLocaleDateString()`: подпись рендерит
+ * серверный компонент, и локальная дата там считалась бы по часовому поясу
+ * КОНТЕЙНЕРА — то есть дата на странице зависела бы от того, где её собрали.
+ * Дальше того же правила держится и формат: `landing-facts` печатает свои
+ * даты замеров так же, разбором ISO.
+ *
+ * `null` на неразобранном входе — та же дисциплина, что у всей витрины: нет
+ * величины, нет подписи. «Invalid Date» на публичной странице хуже её
+ * отсутствия.
+ */
+export const runDate = (iso: string | null): string | null => {
+ const m = iso === null ? null : /^(\d{4})-(\d{2})-(\d{2})/u.exec(iso);
+ return m === null ? null : `${m[3]}.${m[2]}.${m[1]}`;
+};
+
/** «2-к, 52 м²» — всё, что про объект известно наверняка. */
/**
* Заголовок сделки. `rooms = 0` — это СТУДИЯ, а не «ноль комнат»: так её
--
2.45.3
From e6e7a8db1ccc678b699f51b4e83a3fec07fdc0ec Mon Sep 17 00:00:00 2001
From: bot-backend
Date: Sat, 12 Sep 2026 21:29:45 +0500
Subject: [PATCH 2/2] =?UTF-8?q?fix(mera):=20=D0=B6=D0=B8=D0=B2=D0=BE=D0=B9?=
=?UTF-8?q?=20=D1=82=D0=B5=D1=81=D1=82=20=D0=B2=D0=B8=D1=82=D1=80=D0=B8?=
=?UTF-8?q?=D0=BD=D1=8B=20=D0=B3=D0=BE=D0=BD=D1=8F=D0=B5=D1=82=D1=81=D1=8F?=
=?UTF-8?q?=20=D0=BD=D0=B0=20=D0=BF=D0=BE=D0=BB=D0=BD=D0=BE=D0=B9=20=D1=81?=
=?UTF-8?q?=D1=85=D0=B5=D0=BC=D0=B5,=20=D0=B0=20=D0=BD=D0=B5=20=D1=82?=
=?UTF-8?q?=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=BD=D0=B0=20=D0=BF=D1=83?=
=?UTF-8?q?=D1=81=D1=82=D0=BE=D0=B9=20(#3469)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Тест применял 015/051/052 безусловно, и на базе, прошедшей всю цепочку
миграций (CI и прод), повтор 015 падал:
psycopg.errors.UndefinedColumn: column "returning_count" of relation
"scrape_runs" does not exist
Файл 015 идемпотентен относительно себя, но не относительно схемы,
прошедшей 214 (DROP COLUMN IF EXISTS returning_count): CREATE TABLE IF
NOT EXISTS — no-op, а COMMENT ON COLUMN в конце того же файла обращается
к снесённой колонке. На чистой базе, где 015 ложится с нуля, этого не
видно по построению — потому прогон и был зелёным там, где его гонял я,
и красным там, где его гоняет CI.
Зависимости теперь применяются только когда scrape_schedules ещё нет; в
докстроке — рецепт прогона на ПОЛНОЙ схеме, тем же путём, что у CI.
Заодно закрыты три дыры, которые находились мутациями:
- окно расписания и повторное применение проверяются на живой БД (до
этого 6,7 → 6,23 и DELETE+INSERT вместо ON CONFLICT проходили насквозь);
идемпотентность меряется created_at строки, а не числом строк — замена
«удалить и вставить» тоже оставляет ровно одну строку, но стирает
last_run_at/next_run_at на каждом деплое;
- next_run_at в будущем — утверждение стояло в приёмке и ничем не
проверялось;
- handler сравнивается по САМОМУ job'у, а не по log_name: имя — второй
литерал конструктора Handler, и чужое тело под верным ключом
(_job_landing_stats) проходило проверку по имени.
Co-Authored-By: Claude Opus 5
---
.../tests/test_3469_showcase_schedule.py | 60 +++++++++++++++----
1 file changed, 49 insertions(+), 11 deletions(-)
diff --git a/tradein-mvp/backend/tests/test_3469_showcase_schedule.py b/tradein-mvp/backend/tests/test_3469_showcase_schedule.py
index 1f8453c4..4394079b 100644
--- a/tradein-mvp/backend/tests/test_3469_showcase_schedule.py
+++ b/tradein-mvp/backend/tests/test_3469_showcase_schedule.py
@@ -25,14 +25,23 @@
повторное применение её не задваивает, и настоящий запрос сводки
`_STALE_SOURCES_SQL` видит эту строку и отдаёт витрину просроченной.
-ЖИВОЙ ТЕСТ НЕ УДАЛЯЕТ И НЕ ПРАВИТ НИЧЕГО ЧУЖОГО: он применяет те же идемпотентные
-файлы, что применяет деплой (CREATE TABLE IF NOT EXISTS + ON CONFLICT DO NOTHING).
-В CI он ИДЁТ — ci-tradein.yml поднимает свой Postgres и кладёт DSN в DATABASE_URL;
-на машине без базы само-скипается (запись в tests/skip_allowlist.txt). Поднять
-локально:
+ЖИВОЙ ТЕСТ НЕ УДАЛЯЕТ И НЕ ПРАВИТ НИЧЕГО ЧУЖОГО: он применяет ту же идемпотентную
+миграцию, что применяет деплой (ON CONFLICT DO NOTHING). В CI он ИДЁТ —
+ci-tradein.yml поднимает свой Postgres и кладёт DSN в DATABASE_URL; на машине без
+базы само-скипается (запись в tests/skip_allowlist.txt).
- docker exec tradein-postgres psql -U tradein -c 'CREATE DATABASE t3469'
- DATABASE_URL="postgresql+psycopg://tradein:tradein@127.0.0.1:5433/t3469" \
+ГОНЯТЬ ЕГО НАДО НА ПОЛНОЙ СХЕМЕ, А НЕ НА ПУСТОЙ БАЗЕ. На чистой базе он был
+зелёным и при этом падал в CI: повтор `015_scrape_runs.sql` (его комментарий к
+колонке, снесённой миграцией 214) на полной схеме валится, а на пустой — нет.
+Поэтому зависимости применяются только когда таблицы ещё нет, а проверять надо
+тем же путём, каким гоняет CI:
+
+ docker exec tradein-postgres psql -U tradein -d postgres -c 'CREATE DATABASE t3469full'
+ docker exec -i tradein-postgres psql -U tradein -d t3469full -c \\
+ 'CREATE EXTENSION postgis; CREATE EXTENSION pg_trgm; CREATE ROLE gendesign_reader;'
+ for f in $(ls -1 data/sql/*.sql | sort); do docker exec -i tradein-postgres \\
+ psql -U tradein -d t3469full -v ON_ERROR_STOP=on -q < "$f"; done
+ DATABASE_URL="postgresql+psycopg://tradein:tradein@127.0.0.1:5433/t3469full" \\
uv run python -m pytest tests/test_3469_showcase_schedule.py -q
"""
@@ -51,7 +60,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:
from scraper_kit.orchestration import scheduler as sched
-from app.services.product_handlers import build_product_handlers
+from app.services.product_handlers import _job_landing_showcase_deals, build_product_handlers
SOURCE = "landing_showcase_deals"
@@ -92,6 +101,13 @@ def test_handler_resolves_for_scheduler() -> None:
registry = build_product_handlers(ctx=None) # type: ignore[arg-type]
handler = sched.resolve_handler(SOURCE, registry)
assert handler is not None, f"{SOURCE} не резолвится реестром — задача невидима"
+ # СРАВНИВАЕМ САМ JOB, А НЕ `log_name`: имя — второй литерал конструктора
+ # Handler, и правильный ключ с чужим телом (`_job_landing_stats` под ключом
+ # витрины) проходил проверку по имени насквозь. Резолв ведёт к пересчёту
+ # витрины или не ведёт — это свойство функции, а не подписи в логе.
+ assert handler.job is _job_landing_showcase_deals, (
+ f"под ключом {SOURCE} стоит чужой job: {handler.job.__name__}"
+ )
assert handler.log_name == SOURCE
@@ -250,14 +266,29 @@ def test_live_migration_puts_showcase_into_schedules_and_digest() -> None:
db = _live_session()
assert db is not None
try:
- for dep in _DEPS:
- _apply(db, dep)
+ # Зависимости — ТОЛЬКО на пустой базе. На базе, прошедшей всю цепочку
+ # (CI и прод), повтор 015 падает: `CREATE TABLE IF NOT EXISTS` — no-op,
+ # а `COMMENT ON COLUMN scrape_runs.returning_count` внизу того же файла
+ # обращается к колонке, которую снесла 214. Файл идемпотентен
+ # относительно себя, но не относительно схемы, прошедшей 214, — и
+ # прогон на чистой базе этого не видит по построению.
+ if db.execute(text("SELECT to_regclass('public.scrape_schedules')")).scalar() is None:
+ for dep in _DEPS:
+ _apply(db, dep)
+ _apply(db, _MIGRATION)
+ # ИДЕМПОТЕНТНОСТЬ МЕРЯЕТСЯ ПО СОСТОЯНИЮ СТРОКИ, А НЕ ПО ЧИСЛУ СТРОК.
+ # «DELETE + INSERT» тоже оставляет ровно одну строку, но на КАЖДОМ
+ # деплое стирает last_run_at/next_run_at и взводит расписание заново —
+ # счёт строк такую замену не отличает, а created_at отличает.
+ first_created_at = db.execute(
+ text("SELECT created_at FROM scrape_schedules WHERE source = :s"), {"s": SOURCE}
+ ).scalar()
_apply(db, _MIGRATION)
- _apply(db, _MIGRATION) # идемпотентность: второй прогон не задваивает
rows = db.execute(
text(
"SELECT enabled, window_start_hour, window_end_hour, created_at, "
+ " (next_run_at > now()) AS next_run_ahead, "
" default_params->>'interval_days' AS interval_days "
"FROM scrape_schedules WHERE source = :s"
),
@@ -265,9 +296,16 @@ def test_live_migration_puts_showcase_into_schedules_and_digest() -> None:
).fetchall()
assert len(rows) == 1, f"ожидалась одна строка расписания, получено {len(rows)}"
row = rows[0]
+ assert row.created_at == first_created_at, (
+ "повторное применение пересоздало строку расписания — на каждом деплое "
+ "это стирало бы состояние прогонов (last_run_at/next_run_at)"
+ )
assert row.enabled is True
assert (row.window_start_hour, row.window_end_hour) == (6, 7)
assert int(row.interval_days) == _seeded_interval_days()
+ # next_run_at в БУДУЩЕМ: сев расписания не должен выстреливать прогоном
+ # в момент деплоя (образец — 162/275). Стоит в приёмке, значит и здесь.
+ assert row.next_run_ahead is True, "next_run_at в прошлом — прогон стартует на деплое"
# Сводка: прогонов у витрины нет, возраст считается от created_at строки.
digest_rows = list(db.execute(sched._STALE_SOURCES_SQL).fetchall())
--
2.45.3