fix(mera): витрина сделок лэндинга — в расписание, дата прогона на страницу, монитор свежести (#3469) #3509

Merged
bot-backend merged 2 commits from fix/3469-showcase-schedule into main 2026-09-12 17:12:30 +00:00
7 changed files with 509 additions and 8 deletions

View file

@ -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"

View file

@ -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:0007:00 UTC (11:0012:00 по Екатеринбургу): после импорта сделок
-- Росреестра (rosreestr_dkp_import, окно 0406) и после landing_stats_refresh
-- (0506) — витрина считается по уже обновлённым за ночь данным; и за два часа
-- до deals_freshness_monitor (0809), так что утренний пересчёт успевает
-- сняться с просрочки до утренней же проверки.
--
-- 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;

View file

@ -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-лэйна хук их и

View file

@ -0,0 +1,324 @@
"""Витрина сделок лэндинга попадает в расписание и в монитор свежести (#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` видит эту строку и отдаёт витрину просроченной.
ЖИВОЙ ТЕСТ НЕ УДАЛЯЕТ И НЕ ПРАВИТ НИЧЕГО ЧУЖОГО: он применяет ту же идемпотентную
миграцию, что применяет деплой (ON CONFLICT DO NOTHING). В CI он ИДЁТ
ci-tradein.yml поднимает свой Postgres и кладёт DSN в DATABASE_URL; на машине без
базы само-скипается (запись в tests/skip_allowlist.txt).
ГОНЯТЬ ЕГО НАДО НА ПОЛНОЙ СХЕМЕ, А НЕ НА ПУСТОЙ БАЗЕ. На чистой базе он был
зелёным и при этом падал в 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
"""
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 _job_landing_showcase_deals, 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} не резолвится реестром — задача невидима"
# СРАВНИВАЕМ САМ 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
# ── 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:
# Зависимости — ТОЛЬКО на пустой базе. На базе, прошедшей всю цепочку
# (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)
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"
),
{"s": SOURCE},
).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())
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()

View file

@ -97,6 +97,41 @@ describe("витрина лэндинга v3 без данных", () => {
expect(screen.getByText(/рассмотрено сделок: 4 000/)).toBeTruthy();
});
/**
* ДАТА ПРОГОНА НА СТРАНИЦЕ, РЯДОМ СО СЧЁТЧИКАМИ (#3469).
*
* Счётчики («рассмотрено 200, показано 20») не стареют на вид, а строки под
* ними стареют: на проде задачи не было в расписании, и витрина тринадцать
* суток показывала прогон от 30.08 неотличимо от вчерашнего. Проверяется
* ПО ЗНАЧЕНИЮ: дата берётся из `computed_at` фикстуры, вписать её в разметку
* руками и остаться зелёным нельзя второй кейс отдаёт другую дату.
*/
it("«Точность»: под таблицей стоит дата пересчёта витрины, и она из данных", () => {
const { unmount } = render(<AccuracyV3 stats={STATS} showcase={SHOWCASE} />);
expect(screen.getByText(/Витрина пересчитана 29\.08\.2026/u)).toBeTruthy();
unmount();
render(
<AccuracyV3
stats={STATS}
showcase={{ ...SHOWCASE, computed_at: "2026-09-12T06:12:03+00:00" }}
/>,
);
expect(screen.getByText(/Витрина пересчитана 12\.09\.2026/u)).toBeTruthy();
});
/**
* Даты нет нет и предложения про неё: то же правило «нет величины нет
* подписи», что у всего блока. «Invalid Date» или «null» на публичной
* странице хуже отсутствия даты, а «сегодня» на её месте враньё.
*/
it("«Точность»: без computed_at подпись не выдумывает дату", () => {
render(<AccuracyV3 stats={STATS} showcase={{ ...SHOWCASE, computed_at: null }} />);
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 %
* под ним хуже, чем отсутствие утверждения.

View file

@ -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 (
<section
@ -271,16 +276,26 @@ export function AccuracyV3({
(`rejection_rule`) приходит из того же прогона и стоит в этой же
подписи счётчики и правило порознь не показываются.
*/}
{/*
ДАТА ПРОГОНА СТОИТ РЯДОМ СО СЧЁТЧИКАМИ (#3469). Счётчики описывают
прогон, но не говорят, КОГДА он был, и замороженная витрина
выглядела ровно так же, как вчера пересчитанная. На проде это и
случилось: задачи не было в расписании, и страница тринадцать
суток показывала строки от 30.08 без единого признака возраста.
Даты нет (ручка отдала null) предложения тоже нет: подставлять
на её место «сегодня» нельзя.
*/}
<p className={styles.accFootnote}>
{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}`
: "Подпись прогона не пришла — из чего отобраны строки, сказать нечем."}
</p>
{spread && (
// УТВЕРЖДЕНИЕ ПРО ПОЛОСУ — САМОПРОВЕРЯЕМОЕ. Границы стоят в коде
// фронта, а строки приходят из БД, из последнего прогона задачи;
// задача в расписании не стоит и запускается руками. Между
// выкатом и пересчётом страница утверждала бы полосу над
// такт пересчёта — сутки (#3469), то есть строки старше выката
// фронта почти всегда. Между выкатом и пересчётом страница
// утверждала бы полосу над
// строками, собранными до неё (на проде сейчас 12 из 20 вне
// полосы, худшая +75,7 %) — утверждение и его опровержение в
// одном экране. Поэтому про полосу говорим, только если ни одна

View file

@ -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` это СТУДИЯ, а не «ноль комнат»: так её