gendesign/tradein-mvp/backend/data/sql/275_landing_stats.sql
bot-backend b5645ec1bc
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
feat(mera/b2c): витринные метрики лэндинга считаются по проду
Числа на публичном лэндинге лежали литералами во фронте
(mera-public/marketing-v3.ts) — то есть были выдуманы и не имели срока
годности. Теперь их считает ночная задача и отдаёт публичная ручка,
вместе с размером выборки и описанием того, что именно измерено.

Что считается: число расчётов и период работы, медиана аналогов на
расчёт, медианная ЭКСПОЗИЦИЯ активного объявления по ЕКБ (не срок
продажи — так и написано в note), доля снижавших цену и медианное
снижение за 30 дней, сделки Росреестра по ЕКБ за 12 месяцев.

Ценовые метрики берут ТОЛЬКО domklik: у avito/yandex триггер не пишет
стартовую цену, а yandex вдобавок сеет синтетическую пару со сдвигом в
сутки — на такой смеси «снизил» и «не снижал» неразличимы. Знаменатель
доли — все объявления, наблюдавшиеся от 14 дней, включая не менявшие
цену; считая только по менявшим, получили бы 85% вместо честных 48%.

Метрика без входных данных строку НЕ пишет: подставленный ноль читался
бы как измеренный ноль. Пустая таблица — валидные {} и 200, а не 500.

«Точность прогноза» и «срок продажи» здесь не считаются намеренно —
таких величин в данных нет.
2026-08-29 18:45:26 +05:00

90 lines
5.6 KiB
PL/PgSQL
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

-- 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:0006:00 UTC: после ночных лоадеров листингов и после
-- asking_to_sold_ratio_refresh (06:0007: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;