All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 13s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m13s
Числа на публичном лэндинге лежали литералами во фронте
(mera-public/marketing-v3.ts) — то есть были выдуманы и не имели срока
годности. Теперь их считает ночная задача и отдаёт публичная ручка,
вместе с размером выборки и описанием того, что именно измерено.
Что считается: число расчётов и период работы, медиана аналогов на
расчёт, медианная ЭКСПОЗИЦИЯ активного объявления по ЕКБ (не срок
продажи — так и написано в note), доля снижавших цену и медианное
снижение за 30 дней, сделки Росреестра по ЕКБ за 12 месяцев.
Ценовые метрики берут ТОЛЬКО domklik: у avito/yandex триггер не пишет
стартовую цену, а yandex вдобавок сеет синтетическую пару со сдвигом в
сутки — на такой смеси «снизил» и «не снижал» неразличимы. Знаменатель
доли — все объявления, наблюдавшиеся от 14 дней, включая не менявшие
цену; считая только по менявшим, получили бы 85% вместо честных 48%.
Метрика без входных данных строку НЕ пишет: подставленный ноль читался
бы как измеренный ноль. Пустая таблица — валидные {} и 200, а не 500.
«Точность прогноза» и «срок продажи» здесь не считаются намеренно —
таких величин в данных нет.
90 lines
5.6 KiB
PL/PgSQL
90 lines
5.6 KiB
PL/PgSQL
-- 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;
|