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