-- 275_landing_stats.sql -- Витринные метрики публичного лэндинга МЕРЫ — считаются по проду, не пишутся руками. -- -- ЗАЧЕМ ТАБЛИЦА, А НЕ ЗАПРОС ИЗ РУЧКИ -- ----------------------------------- -- Числа на лэндинге сегодня лежат литералами во фронте -- (frontend/src/app/mera-public/marketing-v3.ts) — то есть выдуманы и не имеют -- срока годности: когда база меняется, страница врёт молча. Но и считать их в -- момент запроса нельзя: медиана по offer_price_history с подзапросами на -- листинг — это секунды на анонимной ручке без авторизации, то есть готовый -- рычаг для DoS. Поэтому срез считает ночная задача -- (app/tasks/landing_stats.py), а ручка отдаёт готовые строки. -- -- ОДНА СТРОКА НА МЕТРИКУ, ИСТОРИИ НЕТ -- ----------------------------------- -- PK (metric) + UPSERT: лэндингу нужно «сколько сейчас», а не тренд. Заводить -- историю впрок значит выбрать схему под запрос, которого никто не задавал; -- когда понадобится динамика — она приедет отдельной таблицей со своим PK, -- и это будет дешевле, чем сейчас угадывать её ключ. -- -- value_num И value_text РАЗДЕЛЬНО -- ------------------------------- -- Числовые метрики фронт форматирует сам (округление, склонение, разделители -- разрядов), поэтому числу нельзя приезжать строкой. value_text оставлен для -- метрик, у которых значение — не число (например период «май–август 2026»); -- сегодня такие не пишутся, но колонка дешевле, чем миграция под первую же. -- -- sample_n ОБЯЗАТЕЛЕН ПО СМЫСЛУ, NULL ПО СХЕМЕ -- ------------------------------------------- -- Требование продукта: у каждой витринной цифры видно, по скольким наблюдениям -- она получена — иначе «медиана» неотличима от «медиана по двум объявлениям». -- Гарантирует это задача (она НЕ пишет строку, если входа нет), а не NOT NULL: -- жёсткое ограничение на колонке заставило бы будущую метрику без выборки -- подставлять фиктивный ноль, то есть врать ради схемы. -- -- Идемпотентно: IF NOT EXISTS — безопасно переприменять. BEGIN; CREATE TABLE IF NOT EXISTS landing_stats ( metric text PRIMARY KEY, value_num numeric, value_text text, sample_n integer, note text, computed_at timestamptz NOT NULL DEFAULT now() ); COMMENT ON TABLE landing_stats IS 'Витринные метрики лэндинга МЕРЫ; пересчёт — app/tasks/landing_stats.py (раз в сутки)'; COMMENT ON COLUMN landing_stats.sample_n IS 'Размер выборки, по которой получено значение — показывается рядом с цифрой'; COMMENT ON COLUMN landing_stats.note IS 'Что именно измерено, человеческим языком — защита от подмены смысла на витрине'; -- ── Регистрация в планировщике ────────────────────────────────────────────── -- -- Периодические задачи МЕРЫ живут не в crontab, а строками scrape_schedules: -- kit-scheduler (app/scheduler_main.py) выбирает source по окну и резолвит -- обработчик через app/services/product_handlers.py. Поэтому «регистрация» -- задачи — это ровно две вещи: Handler в реестре и вот эта строка. -- -- Окно 05:00–06:00 UTC: после ночных лоадеров листингов и после -- asking_to_sold_ratio_refresh (06:00–07:00 UTC мы бы догоняли), но до -- рабочего дня по Екатеринбургу (UTC+5) — витрина к утру уже пересчитана. -- Задача читающая (несколько агрегирующих SELECT, внешних вызовов нет), так -- что enabled=true сразу: цена ошибки — минуты CPU ночью. -- -- next_run_at на завтра: не выстреливает прямо в момент деплоя (образец — -- 162_seed_deals_freshness_monitor.sql). INSERT INTO scrape_schedules ( source, enabled, window_start_hour, window_end_hour, next_run_at, default_params ) VALUES ( 'landing_stats_refresh', true, 5, 6, ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 5)) AT TIME ZONE 'UTC', '{}'::jsonb ) ON CONFLICT (source) DO NOTHING; COMMIT;