From b5645ec1bc9a3ae36f6e5ded40dd9170eb84ab27 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 29 Aug 2026 18:45:26 +0500 Subject: [PATCH 1/2] =?UTF-8?q?feat(mera/b2c):=20=D0=B2=D0=B8=D1=82=D1=80?= =?UTF-8?q?=D0=B8=D0=BD=D0=BD=D1=8B=D0=B5=20=D0=BC=D0=B5=D1=82=D1=80=D0=B8?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=BB=D1=8D=D0=BD=D0=B4=D0=B8=D0=BD=D0=B3=D0=B0?= =?UTF-8?q?=20=D1=81=D1=87=D0=B8=D1=82=D0=B0=D1=8E=D1=82=D1=81=D1=8F=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D0=BF=D1=80=D0=BE=D0=B4=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Числа на публичном лэндинге лежали литералами во фронте (mera-public/marketing-v3.ts) — то есть были выдуманы и не имели срока годности. Теперь их считает ночная задача и отдаёт публичная ручка, вместе с размером выборки и описанием того, что именно измерено. Что считается: число расчётов и период работы, медиана аналогов на расчёт, медианная ЭКСПОЗИЦИЯ активного объявления по ЕКБ (не срок продажи — так и написано в note), доля снижавших цену и медианное снижение за 30 дней, сделки Росреестра по ЕКБ за 12 месяцев. Ценовые метрики берут ТОЛЬКО domklik: у avito/yandex триггер не пишет стартовую цену, а yandex вдобавок сеет синтетическую пару со сдвигом в сутки — на такой смеси «снизил» и «не снижал» неразличимы. Знаменатель доли — все объявления, наблюдавшиеся от 14 дней, включая не менявшие цену; считая только по менявшим, получили бы 85% вместо честных 48%. Метрика без входных данных строку НЕ пишет: подставленный ноль читался бы как измеренный ноль. Пустая таблица — валидные {} и 200, а не 500. «Точность прогноза» и «срок продажи» здесь не считаются намеренно — таких величин в данных нет. --- tradein-mvp/backend/app/api/public/mera.py | 68 +++ tradein-mvp/backend/app/core/rbac.py | 4 + .../backend/app/services/product_handlers.py | 11 + .../backend/app/tasks/landing_stats.py | 372 +++++++++++++++++ .../backend/data/sql/275_landing_stats.sql | 90 ++++ .../backend/tests/test_landing_stats.py | 387 ++++++++++++++++++ .../backend/tests/test_public_mera_api.py | 6 +- .../test_scraper_kit_scheduler_parity.py | 1 + 8 files changed, 937 insertions(+), 2 deletions(-) create mode 100644 tradein-mvp/backend/app/tasks/landing_stats.py create mode 100644 tradein-mvp/backend/data/sql/275_landing_stats.sql create mode 100644 tradein-mvp/backend/tests/test_landing_stats.py diff --git a/tradein-mvp/backend/app/api/public/mera.py b/tradein-mvp/backend/app/api/public/mera.py index 3c2bc5f2..ace32b7a 100644 --- a/tradein-mvp/backend/app/api/public/mera.py +++ b/tradein-mvp/backend/app/api/public/mera.py @@ -59,10 +59,12 @@ from __future__ import annotations import asyncio import logging +from datetime import datetime from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel, Field +from sqlalchemy import text from sqlalchemy.orm import Session from app.api.v1.geocode import SuggestResponse, suggest_addresses @@ -286,3 +288,69 @@ def public_coverage( """ _enforce(_coverage_limiter, request, "coverage") return coverage_probe(payload=payload, db=db) + + +class LandingStat(BaseModel): + """Одна витринная величина лэндинга. + + `sample_n` и `note` едут наружу вместе со значением намеренно: цифра без + размера выборки и без описания измеренного — это ровно тот литерал, который + лежал во фронте до появления landing_stats. Пусть фронт решает, показывать + ли их мелким шрифтом, но получить число БЕЗ них он не может. + """ + + value: float | str | None + sample_n: int | None + note: str | None + computed_at: datetime + + +_STATS_LIMIT = 30 +_stats_limiter = SlidingWindowLimiter(limit=_STATS_LIMIT, window_s=_WINDOW_S) + +# Все строки витрины — их единицы, LIMIT не нужен, но потолок пусть будет: +# таблица наполняется только ночной задачей, и если она когда-нибудь начнёт +# писать метрику на город, ручка не должна молча вырасти в мегабайты. +_STATS_SQL = text(""" + SELECT metric, value_num, value_text, sample_n, note, computed_at + FROM landing_stats + ORDER BY metric + LIMIT 200 +""") + + +@router.get("/stats", response_model=dict[str, LandingStat]) +def public_stats( + request: Request, + db: Annotated[Session, Depends(get_db)], +) -> dict[str, LandingStat]: + """Витринные метрики лэндинга — готовый ночной срез (issue: числа по проду). + + GET, в отличие от соседей: здесь в запросе нет ни адреса, ни чего-либо + относящегося к посетителю, поэтому довод «URI попадает в access-лог» не + работает, а кэшируемость GET'а для страницы, которую открывают все, полезна. + + Читает готовые строки, НЕ считает на лету: агрегаты по offer_price_history с + подзапросами на листинг — секунды, а анонимная ручка, которая стоит секунду + CPU, это рычаг DoS. Считает их app/tasks/landing_stats.py раз в сутки. + + Пустая таблица — валидные `{}` и 200. Это штатное состояние сразу после + накатки миграции (задача ещё не отработала) и оно же — состояние «данных для + метрики нет»: задача не пишет строку, когда мерить нечего. Фронт обязан это + пережить и не рисовать блок, а не получить 500 и сломанную страницу. + + `value` — числовое value_num, если оно есть; иначе value_text (для метрик, + у которых значение не число). Оба NULL — отдаём null, а не выдуманный ноль. + """ + _enforce(_stats_limiter, request, "stats") + + rows = db.execute(_STATS_SQL).fetchall() + return { + row.metric: LandingStat( + value=(float(row.value_num) if row.value_num is not None else row.value_text), + sample_n=row.sample_n, + note=row.note, + computed_at=row.computed_at, + ) + for row in rows + } diff --git a/tradein-mvp/backend/app/core/rbac.py b/tradein-mvp/backend/app/core/rbac.py index 7966280d..b4f9471b 100644 --- a/tradein-mvp/backend/app/core/rbac.py +++ b/tradein-mvp/backend/app/core/rbac.py @@ -114,6 +114,10 @@ _PUBLIC_PATHS = frozenset( # держится на структуре пакета app/api/public/, а не на матчере. "/api/public/mera/suggest", "/api/public/mera/coverage", + # Витринные числа лэндинга (landing_stats, миграция 275): агрегаты по + # проду без единой персональной строки — их и показывают анонимному + # посетителю, ради чего метрики и считаются. + "/api/public/mera/stats", } ) # #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед diff --git a/tradein-mvp/backend/app/services/product_handlers.py b/tradein-mvp/backend/app/services/product_handlers.py index 4076fa67..f3285747 100644 --- a/tradein-mvp/backend/app/services/product_handlers.py +++ b/tradein-mvp/backend/app/services/product_handlers.py @@ -308,6 +308,16 @@ async def _job_deals_freshness_monitor( await loop.run_in_executor(None, check_deals_freshness, db, run_id, params) +# ── landing_stats_refresh — sync DB-only пересчёт витрины в executor ───────── +async def _job_landing_stats( + db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext +) -> None: + from app.tasks.landing_stats import refresh_landing_stats + + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, refresh_landing_stats, db, run_id, params) + + # ── 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 @@ -657,6 +667,7 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]: "rosreestr_quarter_poll": Handler(_job_rosreestr_quarter_poll, "rosreestr_quarter_poll"), "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"), "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/app/tasks/landing_stats.py b/tradein-mvp/backend/app/tasks/landing_stats.py new file mode 100644 index 00000000..2767035b --- /dev/null +++ b/tradein-mvp/backend/app/tasks/landing_stats.py @@ -0,0 +1,372 @@ +"""Пересчёт витринных метрик публичного лэндинга МЕРЫ (таблица landing_stats). + +ЗАЧЕМ +----- +Числа на лэндинге (frontend/src/app/mera-public/marketing-v3.ts) были литералами +— то есть придуманными. Публичная страница, которая продаёт «расчёт по данным», +не может показывать цифры, которых в данных нет: это ровно та подмена, против +которой продукт и позиционируется. Здесь каждая витринная величина считается +запросом к проду, и вместе с ней пишется размер выборки. + +ГЛАВНОЕ ПРАВИЛО: НЕТ ВХОДА — НЕТ СТРОКИ +--------------------------------------- +Ни одна метрика не пишется с подставленным значением. Если выборка пуста +(нет оценок, нет истории цен, нет сделок) — строка в landing_stats просто не +появляется, ручка её не отдаёт, фронт не рисует блок. Ноль здесь читался бы как +измеренный ноль («ни одно объявление не снижало цену»), а это враньё другого +рода, чем отсутствие данных. + +ЧЕГО ЗДЕСЬ НЕТ И НЕ БУДЕТ +------------------------- +«Точность прогноза» и «срок продажи» — величин с такими именами в базе нет. +Точность считает бэктест (своя задача, свои допущения), а срок продажи требует +пары «объявление снято → сделка», которой у нас нет: снятие объявления не +означает продажу. `listing_age_median_days` НЕ является сроком продажи и назван +экспозицией активного объявления — см. note метрики. + +ПОЧЕМУ ТОЛЬКО DOMKLIK В ЦЕНОВЫХ МЕТРИКАХ +---------------------------------------- +`offer_price_history` наполняется триггером, и наполняется по-разному: +у avito/yandex стартовая цена в историю НЕ пишется (первая строка появляется +только при изменении, то есть «снизил» и «не снижал» неразличимы), а yandex +вдобавок сеет синтетическую пару со сдвигом в сутки. Считать долю снижений по +такой смеси — значит получить число, у которого нет смысла. Domklik пишет старт, +поэтому только он. + +Задача синхронная (только SELECT'ы + UPSERT), запускается kit-scheduler'ом через +product_handlers._job_landing_stats в run_in_executor — по образцу +deals_freshness_monitor / listing_source_snapshot. +""" + +from __future__ import annotations + +import logging +from decimal import Decimal +from typing import Any + +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.services import scrape_runs as runs_mod + +logger = logging.getLogger(__name__) + +__all__ = ["EKB", "collect_landing_metrics", "refresh_landing_stats"] + +# Город витрины. Лэндинг сегодня продаёт Екатеринбург, и метрики обязаны быть +# про него же: медиана по всей области смешала бы рынки с разной динамикой. +EKB = "Екатеринбург" + +# Порог наблюдения для ценовых метрик. За две недели объявление успевает получить +# первую правку цены; более короткие живут слишком мало, чтобы «не снижал» было +# наблюдением, а не «не успел». +_PRICE_SPAN_DAYS = 14 +# Отсечка аномалий: изменение больше 30% за наблюдение — это, как правило, смена +# объекта под тем же id (перевыставили другую квартиру) или опечатка в цене, +# а не торг. Медиану такие хвосты не двигают, но долю снижений — двигают. +_PRICE_MAX_ABS_PCT = 30 + +# ── Оценки ────────────────────────────────────────────────────────────────── +# Период считаем по фактическим краям created_at, а не «с даты запуска»: витрина +# обещает «за N дней работы», и N должен быть измеренным. +_ESTIMATES_SQL = text(""" + SELECT count(*) AS total, + EXTRACT(EPOCH FROM (max(created_at) - min(created_at))) + / 86400.0 AS period_days + FROM trade_in_estimates +""") + +# n_analogs > 0: оценка без аналогов — это отказ расчёта, а не «ноль аналогов»; +# включив её, мы бы занизили медиану наблюдениями, где измерять было нечего. +_ANALOGS_SQL = text(""" + SELECT count(*) AS n, + percentile_cont(0.5) WITHIN GROUP (ORDER BY n_analogs) AS median + FROM trade_in_estimates + WHERE n_analogs > 0 +""") + +# Возраст АКТИВНОГО объявления = экспозиция на сегодня, а не срок продажи: +# знаменатель — те, кто ещё висит, поэтому величина по построению занижена +# относительно «сколько в итоге продавалось». Это ограничение уезжает в note. +_LISTING_AGE_SQL = text(""" + SELECT count(*) AS n, + percentile_cont(0.5) WITHIN GROUP ( + ORDER BY (CURRENT_DATE - listing_date) + ) AS median + FROM listings + WHERE is_active + AND city = CAST(:city AS text) + AND listing_date IS NOT NULL + AND listing_date <= CURRENT_DATE +""") + +# ── Динамика цены объявлений ──────────────────────────────────────────────── +# +# Знаменатель — объявления, которые МОЖНО было наблюдать: от первой записи в +# истории до последнего показа прошло >= 14 дней. Сюда попадают и те, у кого +# запись одна (domklik пишет старт → одна запись означает «цену не менял»); без +# них доля снижений считалась бы только по менявшим и давала 85% вместо 48%. +# +# Скорость снижения нормируем на 30 дней по интервалу МЕЖДУ КРАЙНИМИ ПРАВКАМИ, +# а не по всему наблюдению: цена не менялась после последней правки, и растягивая +# знаменатель на «висит до сих пор», мы измеряли бы терпение продавца, а не торг. +_PRICE_MOVES_SQL = text(""" + WITH hist AS ( + SELECT listing_id, + min(change_time) AS first_change, + max(change_time) AS last_change, + count(*) AS n_rows + FROM offer_price_history + WHERE source = 'domklik' + GROUP BY listing_id + ), + observed AS ( + SELECT h.listing_id, + h.n_rows, + EXTRACT(EPOCH FROM (h.last_change - h.first_change)) / 86400.0 AS change_days + FROM hist h + JOIN listings l ON l.id = h.listing_id + WHERE GREATEST(h.last_change, COALESCE(l.last_seen_at, h.last_change)) - h.first_change + >= make_interval(days => CAST(:span_days AS integer)) + ), + priced AS ( + SELECT o.listing_id, + o.n_rows, + o.change_days, + (SELECT p.price_rub FROM offer_price_history p + WHERE p.listing_id = o.listing_id AND p.source = 'domklik' + ORDER BY p.change_time ASC, p.id ASC LIMIT 1) AS price_first, + (SELECT p.price_rub FROM offer_price_history p + WHERE p.listing_id = o.listing_id AND p.source = 'domklik' + ORDER BY p.change_time DESC, p.id DESC LIMIT 1) AS price_last + FROM observed o + ), + moved AS ( + SELECT listing_id, + change_days, + CASE WHEN n_rows >= 2 + THEN (price_last - price_first) / price_first * 100.0 + ELSE 0 + END AS pct + FROM priced + WHERE price_first IS NOT NULL AND price_first > 0 + ) + SELECT count(*) AS n, + count(*) FILTER (WHERE pct < 0) AS n_cut, + percentile_cont(0.5) WITHIN GROUP ( + ORDER BY pct * 30.0 / NULLIF(change_days, 0) + ) FILTER (WHERE pct < 0) AS median_pct_per_month + FROM moved + WHERE abs(pct) <= CAST(:max_abs_pct AS numeric) +""") + +# 12 месяцев от сегодня. deal_date у Росреестра — лейбл начала квартала, поэтому +# окно накрывает 4-5 кварталов и число «за год» тут приблизительно по построению; +# это сказано в note, а не спрятано. +_DEALS_SQL = text(""" + SELECT count(*) AS n + FROM deals + WHERE city = CAST(:city AS text) + AND deal_date >= (CURRENT_DATE - INTERVAL '12 months') +""") + +_UPSERT_SQL = text(""" + INSERT INTO landing_stats (metric, value_num, value_text, sample_n, note, computed_at) + VALUES ( + CAST(:metric AS text), + CAST(:value_num AS numeric), + CAST(:value_text AS text), + CAST(:sample_n AS integer), + CAST(:note AS text), + now() + ) + ON CONFLICT (metric) DO UPDATE SET + value_num = EXCLUDED.value_num, + value_text = EXCLUDED.value_text, + sample_n = EXCLUDED.sample_n, + note = EXCLUDED.note, + computed_at = EXCLUDED.computed_at +""") + + +def _num(value: Any) -> float | None: + """Привести значение агрегата к float; None остаётся None. + + percentile_cont возвращает Decimal/float в зависимости от типа входа — в + numeric-колонку и в JSON поедет одинаково только после явного приведения. + """ + if value is None: + return None + if isinstance(value, Decimal): + return float(value) + return float(value) + + +def collect_landing_metrics(db: Session) -> list[dict[str, Any]]: + """Посчитать метрики витрины. Метрика без данных в список НЕ попадает. + + Отделено от записи, чтобы тест мог проверить сами ЗНАЧЕНИЯ на подготовленной + базе, не разбирая по дороге счётчики прогона. + """ + metrics: list[dict[str, Any]] = [] + + row = db.execute(_ESTIMATES_SQL).first() + total = int(row.total) if row is not None and row.total else 0 + if total > 0: + metrics.append( + { + "metric": "estimates_total", + "value_num": float(total), + "value_text": None, + "sample_n": total, + "note": "Расчётов сделано в системе (все города, весь срок работы)", + } + ) + period = _num(row.period_days) + # Один-единственный расчёт даёт период 0 дней — это не измерение, а + # артефакт единственной точки; такую строку не пишем. + if period is not None and total > 1: + metrics.append( + { + "metric": "estimates_period_days", + "value_num": round(period, 1), + "value_text": None, + "sample_n": total, + "note": "Дней между первым и последним расчётом", + } + ) + + row = db.execute(_ANALOGS_SQL).first() + if row is not None and row.n and _num(row.median) is not None: + metrics.append( + { + "metric": "analogs_median", + "value_num": round(_num(row.median) or 0.0, 1), + "value_text": None, + "sample_n": int(row.n), + "note": "Медиана числа аналогов на расчёт (только расчёты, где аналоги нашлись)", + } + ) + + row = db.execute(_LISTING_AGE_SQL, {"city": EKB}).first() + if row is not None and row.n and _num(row.median) is not None: + metrics.append( + { + "metric": "listing_age_median_days", + "value_num": round(_num(row.median) or 0.0, 1), + "value_text": None, + "sample_n": int(row.n), + "note": ( + "Медианная ЭКСПОЗИЦИЯ активного объявления в Екатеринбурге " + "(сколько дней висит на сегодня). Это НЕ срок продажи: " + "считается по тем, кто ещё продаётся, и снятие объявления " + "не означает сделку" + ), + } + ) + + row = db.execute( + _PRICE_MOVES_SQL, + {"span_days": _PRICE_SPAN_DAYS, "max_abs_pct": _PRICE_MAX_ABS_PCT}, + ).first() + if row is not None and row.n: + n = int(row.n) + base_note = ( + f"Только Домклик (единственный источник, где триггер пишет стартовую цену), " + f"наблюдение от {_PRICE_SPAN_DAYS} дней, изменения свыше " + f"{_PRICE_MAX_ABS_PCT}% отброшены как смена объекта" + ) + metrics.append( + { + "metric": "price_cut_share_pct", + "value_num": round(int(row.n_cut) * 100.0 / n, 1), + "value_text": None, + "sample_n": n, + "note": f"Доля объявлений, снижавших цену. {base_note}", + } + ) + median_move = _num(row.median_pct_per_month) + if median_move is not None: + metrics.append( + { + "metric": "price_cut_median_pct_per_month", + "value_num": round(median_move, 2), + "value_text": None, + # Выборка ЗДЕСЬ — только снижавшие: медиана считается по ним, + # и подставить сюда общий n значило бы приписать величине + # выборку, по которой её не считали. + "sample_n": int(row.n_cut), + "note": ( + f"Медианное изменение цены за 30 дней среди снижавших " + f"(отрицательное). {base_note}" + ), + } + ) + + row = db.execute(_DEALS_SQL, {"city": EKB}).first() + if row is not None and row.n: + metrics.append( + { + "metric": "deals_total_12m", + "value_num": float(row.n), + "value_text": None, + "sample_n": int(row.n), + "note": ( + "Сделок Росреестра по Екатеринбургу за последние 12 месяцев. " + "Дата сделки — лейбл начала квартала, поэтому окно накрывает " + "целые кварталы, а не ровно год" + ), + } + ) + + return metrics + + +def refresh_landing_stats( + db: Session, + run_id: int, + params: dict[str, Any] | None = None, +) -> dict[str, int]: + """Пересчитать landing_stats и финализировать прогон. + + Sync (вызывается scheduler-триггером в executor, как check_deals_freshness). + `params` не используется — принимается ради единой сигнатуры обработчиков. + + Пустой результат — НЕ ошибка прогона: на свежей базе метрик может не быть + ни одной, и падать в failed из-за этого значит завести шумный алерт там, где + система работает штатно. Строки при этом не трогаются: старый срез лучше + отсутствующего, а его возраст виден по computed_at. + """ + del params + counters: dict[str, int] = {"metrics_written": 0} + try: + runs_mod.update_heartbeat(db, run_id, counters) + + metrics = collect_landing_metrics(db) + for row in metrics: + db.execute(_UPSERT_SQL, row) + db.commit() + + counters["metrics_written"] = len(metrics) + if not metrics: + logger.warning( + "landing_stats run_id=%d: ни одной метрики не посчиталось — " + "витрина покажет прошлый срез (или пусто, если его не было)", + run_id, + ) + runs_mod.mark_done(db, run_id, counters) + logger.info( + "refresh_landing_stats run_id=%d done: %d метрик (%s)", + run_id, + len(metrics), + ", ".join(m["metric"] for m in metrics) or "—", + ) + return counters + except Exception as exc: + logger.exception("refresh_landing_stats run_id=%d failed", run_id) + try: + db.rollback() + except Exception: + pass + runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters) + raise diff --git a/tradein-mvp/backend/data/sql/275_landing_stats.sql b/tradein-mvp/backend/data/sql/275_landing_stats.sql new file mode 100644 index 00000000..f1371dd1 --- /dev/null +++ b/tradein-mvp/backend/data/sql/275_landing_stats.sql @@ -0,0 +1,90 @@ +-- 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; diff --git a/tradein-mvp/backend/tests/test_landing_stats.py b/tradein-mvp/backend/tests/test_landing_stats.py new file mode 100644 index 00000000..001635a1 --- /dev/null +++ b/tradein-mvp/backend/tests/test_landing_stats.py @@ -0,0 +1,387 @@ +"""Витринные метрики лэндинга — задача пересчёта + публичная ручка. + +ЧТО ЗДЕСЬ ПРОВЕРЯЕТСЯ И ПОЧЕМУ ИМЕННО ЭТО + + 1. ЗНАЧЕНИЯ. Арифметика витрины (доля снижавших, нормировка на 30 дней, + округления, какой sample_n к какой метрике) живёт в Python, и она проверена + по ЧИСЛАМ: подставляем агрегаты и сверяем ровно то, что уедет на страницу. + Тест обязан краснеть, если share посчитать от не того знаменателя или + приписать медиане общий n вместо числа снижавших. + + 2. НЕТ ВХОДА — НЕТ СТРОКИ. Отдельная проверка на каждую пустую выборку: + подстановка правдоподобного нуля — главный способ соврать на витрине, и + запрещена она поведением задачи, а не комментарием. + + 3. ГРАНИЦЫ ВЫБОРКИ В SQL. Условия «только domklik», «наблюдение >= 14 дней», + «|изменение| <= 30%» на mock-сессии не проявляются: их исполняет Postgres. + Поэтому они запинены статически по тексту запроса — иначе их молчаливое + исчезновение (а с ним и мусор от yandex-синтетики) прошло бы незамеченным. + + 4. РУЧКА. Публичность (rbac), форма ответа, и главное — пустая таблица даёт + 200 и {}, а не 500: это штатное состояние сразу после накатки миграции. +""" + +from __future__ import annotations + +import os +import re +import sys +from datetime import UTC, datetime +from decimal import Decimal +from pathlib import Path +from types import SimpleNamespace +from typing import Any +from unittest.mock import MagicMock + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +_wp_mock = MagicMock() +sys.modules.setdefault("weasyprint", _wp_mock) +sys.modules.setdefault("weasyprint.CSS", _wp_mock) +sys.modules.setdefault("weasyprint.HTML", _wp_mock) + +import pytest # noqa: E402 +from fastapi import FastAPI # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 + +from app.api.public import mera as public_mera # noqa: E402 +from app.core.db import get_db # noqa: E402 +from app.core.rbac import _PUBLIC_PATHS, rbac_guard # noqa: E402 +from app.tasks import landing_stats as ls # noqa: E402 + +_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" +_MIGRATION_275 = _SQL_DIR / "275_landing_stats.sql" + +PREFIX = "/api/public/mera" + + +# ── Мок-сессия: отдаёт заранее заданную строку на каждый из запросов задачи ─── +# +# Раскладываем ответы по ПОРЯДКУ вызовов, а не по тексту SQL: порядок — часть +# контракта collect_landing_metrics (он же порядок метрик на витрине), и его +# перестановка должна быть заметна. +class _FakeSession: + def __init__(self, rows: list[Any]) -> None: + self._rows = list(rows) + self.upserts: list[dict[str, Any]] = [] + self.committed = 0 + + # Считываем ТОЛЬКО запросы самой витрины: по этой же сессии ходит + # runs_mod (heartbeat/mark_done пишут в scrape_runs), и если раздавать + # заготовленные строки по любому execute, первый же heartbeat съест + # агрегат оценок — тест краснел бы не по своей причине. + _METRIC_SQL_MARKERS = ( + "FROM trade_in_estimates", + "FROM listings", + "offer_price_history", + "FROM deals", + ) + + def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any: + sql = str(stmt) + if "INSERT INTO landing_stats" in sql: + assert params is not None + self.upserts.append(params) + return MagicMock() + if not any(marker in sql for marker in self._METRIC_SQL_MARKERS): + return MagicMock() + assert self._rows, f"неожиданный лишний SELECT: {sql[:80]}" + row = self._rows.pop(0) + return SimpleNamespace(first=lambda: row) + + def commit(self) -> None: + self.committed += 1 + + def rollback(self) -> None: # pragma: no cover — путь ошибки здесь не гоняется + pass + + +def _rows(**overrides: Any) -> list[Any]: + """Пять агрегатов в порядке вызова. Значения — прод-срез на 29.08.2026.""" + base: dict[str, Any] = { + "estimates": SimpleNamespace(total=1123, period_days=94.0), + "analogs": SimpleNamespace(n=975, median=Decimal("12")), + "listing_age": SimpleNamespace(n=25943, median=Decimal("26")), + "price": SimpleNamespace(n=6276, n_cut=3018, median_pct_per_month=Decimal("-2.174")), + "deals": SimpleNamespace(n=18657), + } + base.update(overrides) + return [base["estimates"], base["analogs"], base["listing_age"], base["price"], base["deals"]] + + +def _by_metric(metrics: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: + return {m["metric"]: m for m in metrics} + + +# ── 1. Значения ────────────────────────────────────────────────────────────── + + +def test_all_metrics_computed_from_aggregates() -> None: + """Каждая витринная цифра — ровно то, что следует из выборки.""" + got = _by_metric(ls.collect_landing_metrics(_FakeSession(_rows()))) + + assert got["estimates_total"]["value_num"] == 1123.0 + assert got["estimates_total"]["sample_n"] == 1123 + assert got["estimates_period_days"]["value_num"] == 94.0 + assert got["analogs_median"]["value_num"] == 12.0 + assert got["analogs_median"]["sample_n"] == 975 + assert got["listing_age_median_days"]["value_num"] == 26.0 + assert got["deals_total_12m"]["value_num"] == 18657.0 + + # 3018/6276 = 48.087...% → 48.1 после округления до десятых. + assert got["price_cut_share_pct"]["value_num"] == 48.1 + assert got["price_cut_share_pct"]["sample_n"] == 6276 + assert got["price_cut_median_pct_per_month"]["value_num"] == -2.17 + # Медиана считается ТОЛЬКО по снижавшим — и выборка у неё их, а не общая. + assert got["price_cut_median_pct_per_month"]["sample_n"] == 3018 + + +def test_share_uses_full_observed_denominator_not_only_cutters() -> None: + """Знаменатель доли — все наблюдавшиеся, а не только снижавшие. + + Если считать от снижавших, доля всегда 100% — ровно тот дефект, который на + проде давал 85% вместо 48% (в выборку попадали только менявшие цену). + """ + rows = _rows(price=SimpleNamespace(n=200, n_cut=50, median_pct_per_month=Decimal("-3"))) + got = _by_metric(ls.collect_landing_metrics(_FakeSession(rows))) + assert got["price_cut_share_pct"]["value_num"] == 25.0 + + +def test_every_metric_carries_sample_n() -> None: + """Цифра без размера выборки неотличима от литерала, ради замены которого + вся эта таблица и заведена.""" + for m in ls.collect_landing_metrics(_FakeSession(_rows())): + assert isinstance(m["sample_n"], int) and m["sample_n"] > 0, m["metric"] + assert m["note"], m["metric"] + + +def test_listing_age_note_says_exposure_not_time_to_sell() -> None: + """Величина по построению — экспозиция ЕЩЁ ВИСЯЩЕГО объявления. Названная + «сроком продажи», она врёт (и врёт в выгодную сторону).""" + got = _by_metric(ls.collect_landing_metrics(_FakeSession(_rows()))) + note = got["listing_age_median_days"]["note"] + assert "ЭКСПОЗИЦИЯ" in note + assert "НЕ срок продажи" in note + + +def test_forecast_accuracy_and_time_to_sell_are_never_produced() -> None: + """Этих величин в данных нет; их считает бэктест со своими допущениями.""" + names = {m["metric"] for m in ls.collect_landing_metrics(_FakeSession(_rows()))} + assert not {n for n in names if "accuracy" in n or "time_to_sell" in n or "days_to_sell" in n} + + +# ── 2. Нет входа — нет строки ──────────────────────────────────────────────── + + +@pytest.mark.parametrize( + ("kwargs", "absent"), + [ + ({"estimates": SimpleNamespace(total=0, period_days=None)}, "estimates_total"), + ({"analogs": SimpleNamespace(n=0, median=None)}, "analogs_median"), + ({"listing_age": SimpleNamespace(n=0, median=None)}, "listing_age_median_days"), + ( + {"price": SimpleNamespace(n=0, n_cut=0, median_pct_per_month=None)}, + "price_cut_share_pct", + ), + ({"deals": SimpleNamespace(n=0)}, "deals_total_12m"), + ], +) +def test_empty_input_writes_no_row_instead_of_zero(kwargs: dict[str, Any], absent: str) -> None: + """Ноль читается как измеренный ноль («никто не снижал цену») — а измерения + не было. Строки просто нет, фронт не рисует блок.""" + names = {m["metric"] for m in ls.collect_landing_metrics(_FakeSession(_rows(**kwargs)))} + assert absent not in names + + +def test_single_estimate_gives_no_period_metric() -> None: + """Период между первым и последним расчётом при одном расчёте — 0 дней, + что является артефактом единственной точки, а не сроком работы.""" + rows = _rows(estimates=SimpleNamespace(total=1, period_days=0.0)) + names = {m["metric"] for m in ls.collect_landing_metrics(_FakeSession(rows))} + assert "estimates_total" in names + assert "estimates_period_days" not in names + + +def test_no_cutters_leaves_share_but_drops_median() -> None: + """Никто не снижал — доля 0% ИЗМЕРЕНА (наблюдения были), а медианы снижения + не существует: писать её нулём значило бы выдумать «снижают на 0%».""" + rows = _rows(price=SimpleNamespace(n=120, n_cut=0, median_pct_per_month=None)) + got = _by_metric(ls.collect_landing_metrics(_FakeSession(rows))) + assert got["price_cut_share_pct"]["value_num"] == 0.0 + assert "price_cut_median_pct_per_month" not in got + + +def test_refresh_upserts_every_metric_and_commits() -> None: + db = _FakeSession(_rows()) + counters = ls.refresh_landing_stats(db, run_id=1) # type: ignore[arg-type] + assert counters["metrics_written"] == len(db.upserts) == 7 + # >=1, а не ==1: runs_mod коммитит свои heartbeat/mark_done по той же сессии. + assert db.committed >= 1 + assert {u["metric"] for u in db.upserts} == { + "estimates_total", + "estimates_period_days", + "analogs_median", + "listing_age_median_days", + "price_cut_share_pct", + "price_cut_median_pct_per_month", + "deals_total_12m", + } + + +# ── 3. Границы выборки, которые исполняет Postgres ─────────────────────────── + + +def test_price_sql_takes_domklik_only() -> None: + """avito/yandex сюда попасть не могут: у первого нет стартовой цены в + истории, второй сеет синтетическую пару со сдвигом в сутки.""" + sql = str(ls._PRICE_MOVES_SQL) + assert "source = 'domklik'" in sql + assert "avito" not in sql and "yandex" not in sql + + +def test_price_sql_keeps_span_and_outlier_gates() -> None: + sql = str(ls._PRICE_MOVES_SQL) + assert "span_days" in sql, "исчез порог наблюдения — короткоживущие дадут ложное «не снижал»" + assert "max_abs_pct" in sql, "исчезла отсечка аномалий — перевыставленные объекты как торг" + assert ls._PRICE_SPAN_DAYS == 14 + assert ls._PRICE_MAX_ABS_PCT == 30 + + +def test_city_scoped_metrics_are_parameterised_by_ekb() -> None: + for sql in (str(ls._LISTING_AGE_SQL), str(ls._DEALS_SQL)): + assert "CAST(:city AS text)" in sql + assert ls.EKB == "Екатеринбург" + + +def test_analogs_median_excludes_estimates_without_analogs() -> None: + """n_analogs=0 — это отказ расчёта, а не «ноль аналогов»; в медиане он + занизил бы величину наблюдением, где мерить было нечего.""" + assert "n_analogs > 0" in str(ls._ANALOGS_SQL) + + +# ── Миграция ───────────────────────────────────────────────────────────────── + + +def test_migration_275_is_idempotent_and_registers_the_job() -> None: + sql = _MIGRATION_275.read_text("utf-8") + assert "CREATE TABLE IF NOT EXISTS landing_stats" in sql + assert "metric text PRIMARY KEY" in sql + assert "ON CONFLICT (source) DO NOTHING" in sql + assert "'landing_stats_refresh'" in sql + + +def test_migration_275_has_no_psycopg_cast_trap() -> None: + """`:x::type` psycopg v3 разбирает как именованный параметр — в проекте + разрешён только CAST(:x AS type).""" + assert not re.search(r":\w+::", _MIGRATION_275.read_text("utf-8")) + + +def test_task_is_registered_in_the_scheduler_registry() -> None: + """Без Handler'а строка расписания резолвится в никуда и джоба не бежит.""" + from app.services.product_handlers import build_product_handlers + + handlers = build_product_handlers(MagicMock()) + assert "landing_stats_refresh" in handlers + + +# ── 4. Публичная ручка ─────────────────────────────────────────────────────── + + +_STAT_ROWS = [ + SimpleNamespace( + metric="estimates_total", + value_num=Decimal("1123"), + value_text=None, + sample_n=1123, + note="Расчётов сделано", + computed_at=datetime(2026, 8, 29, 5, 0, tzinfo=UTC), + ), + SimpleNamespace( + metric="price_cut_share_pct", + value_num=Decimal("48.1"), + value_text=None, + sample_n=6276, + note="Только Домклик", + computed_at=datetime(2026, 8, 29, 5, 0, tzinfo=UTC), + ), +] + + +def _client(rows: list[Any]) -> TestClient: + """Приложение с РЕАЛЬНЫМ rbac_guard — тем же, что вешает app/main.py.""" + app = FastAPI() + app.middleware("http")(rbac_guard) + app.include_router(public_mera.router, prefix=PREFIX) + + db = MagicMock() + db.execute.return_value.fetchall.return_value = rows + + def _override_db(): + yield db + + app.dependency_overrides[get_db] = _override_db + return TestClient(app) + + +@pytest.fixture(autouse=True) +def _reset_stats_limiter(): + public_mera._stats_limiter._hits.clear() + yield + public_mera._stats_limiter._hits.clear() + + +def test_stats_path_is_public_in_rbac() -> None: + assert f"{PREFIX}/stats" in _PUBLIC_PATHS, ( + "без строки в rbac._PUBLIC_PATHS анониму прилетит 401 и лэндинг останется без чисел" + ) + + +def test_anonymous_gets_stats_keyed_by_metric() -> None: + resp = _client(_STAT_ROWS).get(f"{PREFIX}/stats") + assert resp.status_code == 200 + body = resp.json() + assert set(body) == {"estimates_total", "price_cut_share_pct"} + assert body["estimates_total"]["value"] == 1123.0 + assert body["price_cut_share_pct"]["value"] == 48.1 + assert body["price_cut_share_pct"]["sample_n"] == 6276 + assert body["price_cut_share_pct"]["note"] == "Только Домклик" + assert body["estimates_total"]["computed_at"].startswith("2026-08-29T05:00") + + +def test_empty_table_is_a_valid_answer_not_an_error() -> None: + """Состояние сразу после накатки миграции: задача ещё не отрабатывала. + 500 здесь сломал бы страницу целиком ради отсутствующего блока.""" + resp = _client([]).get(f"{PREFIX}/stats") + assert resp.status_code == 200 + assert resp.json() == {} + + +def test_metric_without_numeric_value_falls_back_to_text_then_null() -> None: + rows = [ + SimpleNamespace( + metric="period_label", + value_num=None, + value_text="май–август 2026", + sample_n=1123, + note=None, + computed_at=datetime(2026, 8, 29, tzinfo=UTC), + ), + SimpleNamespace( + metric="nothing_measured", + value_num=None, + value_text=None, + sample_n=None, + note=None, + computed_at=datetime(2026, 8, 29, tzinfo=UTC), + ), + ] + body = _client(rows).get(f"{PREFIX}/stats").json() + assert body["period_label"]["value"] == "май–август 2026" + assert body["nothing_measured"]["value"] is None + + +def test_stats_rate_limited_per_ip() -> None: + client = _client(_STAT_ROWS) + codes = [client.get(f"{PREFIX}/stats").status_code for _ in range(public_mera._STATS_LIMIT + 1)] + assert codes[-1] == 429 + assert set(codes[:-1]) == {200} diff --git a/tradein-mvp/backend/tests/test_public_mera_api.py b/tradein-mvp/backend/tests/test_public_mera_api.py index 5b834927..a2eef695 100644 --- a/tradein-mvp/backend/tests/test_public_mera_api.py +++ b/tradein-mvp/backend/tests/test_public_mera_api.py @@ -75,9 +75,11 @@ def _reset_limiters(): """ public_mera._suggest_limiter._hits.clear() public_mera._coverage_limiter._hits.clear() + public_mera._stats_limiter._hits.clear() yield public_mera._suggest_limiter._hits.clear() public_mera._coverage_limiter._hits.clear() + public_mera._stats_limiter._hits.clear() @pytest.fixture() @@ -105,9 +107,9 @@ def client() -> TestClient: # ── 1-2. Периметр и его связка с rbac ──────────────────────────────────────── -def test_public_router_exposes_exactly_two_routes() -> None: +def test_public_router_exposes_exactly_three_routes() -> None: paths = {r.path for r in public_mera.router.routes} - assert paths == {"/suggest", "/coverage"}, ( + assert paths == {"/suggest", "/coverage", "/stats"}, ( "изменился набор публичных (анонимных) ручек МЕРЫ. Это не рефакторинг: " "всё под /api/public/ проксируется на meraocenka.ru целиком и доступно " "без идентичности. Обнови тест ОСОЗНАННО вместе с rbac._PUBLIC_PATHS." diff --git a/tradein-mvp/backend/tests/test_scraper_kit_scheduler_parity.py b/tradein-mvp/backend/tests/test_scraper_kit_scheduler_parity.py index 781dd7c9..e651265d 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_scheduler_parity.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_scheduler_parity.py @@ -68,6 +68,7 @@ _PRODUCT_SOURCES: set[str] = { "sber_index_pull", "rosreestr_quarter_poll", "deals_freshness_monitor", + "landing_stats_refresh", "newbuilding_enrich", "yandex_newbuilding_sweep", "geoportal_coords_backfill", -- 2.45.3 From e9a2fff0b313326163aa11c1223e559a1597b412 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 29 Aug 2026 19:10:04 +0500 Subject: [PATCH 2/2] =?UTF-8?q?test(mera/b2c):=20=D0=B3=D0=B5=D0=B9=D1=82?= =?UTF-8?q?=20=D0=BD=D0=B0=20=D1=81=D0=B0=D0=BC=20SQL=20=D0=B4=D0=BE=D0=BB?= =?UTF-8?q?=D0=B8=20=D1=81=D0=BD=D0=B8=D0=B6=D0=B5=D0=BD=D0=B8=D0=B9=20+?= =?UTF-8?q?=20=D1=87=D0=B8=D1=81=D1=82=D0=BA=D0=B0=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D1=82=D1=83=D1=85=D1=88=D0=B8=D1=85=20=D0=BC=D0=B5=D1=82=D1=80?= =?UTF-8?q?=D0=B8=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ревью: гейт охранял не то место. Подмена знаменателя красила три теста, но дефект «84.8% вместо 48.1%» живёт в SQL — во включении однострочных записей истории (у domklik одна запись = «цену не менял») в знаменатель. Ревьюер вернул дефект условием n_rows >= 2 в CTE moved, и все 25 тестов остались зелёными: текстовые пины держали только span_days и max_abs_pct. Новый пин держит обе половины: однострочные попадают в moved веткой CASE со значением 0, и нигде в запросе нет фильтра по числу записей истории (ни в WHERE, ни HAVING). Живой прогон на подготовленных строках не заведён намеренно: DATABASE_URL в тестовой джобе — заглушка, Postgres там нет, и тест по образцу test_purge_expired_trade_in_data.py молча скипался бы, то есть не гейтил бы ничего. Фальсифицировано руками — с n_rows >= 2 тест красный и называет причину. Второе: метрика, у которой пропал вход, больше не доживает в таблице со старым computed_at (ручка отдавала её неотличимо от свежей). Строки вне сегодняшнего набора удаляются в той же транзакции. На ПУСТОМ наборе чистка не ходит: разом отвалившиеся все входы — признак поломки прогона, а не пяти одновременных «данных больше нет». Оба поведения покрыты тестами, оба проверены на сломанном коде. --- .../backend/app/tasks/landing_stats.py | 37 ++++++-- .../backend/tests/test_landing_stats.py | 85 ++++++++++++++++++- 2 files changed, 115 insertions(+), 7 deletions(-) diff --git a/tradein-mvp/backend/app/tasks/landing_stats.py b/tradein-mvp/backend/app/tasks/landing_stats.py index 2767035b..b6821e93 100644 --- a/tradein-mvp/backend/app/tasks/landing_stats.py +++ b/tradein-mvp/backend/app/tasks/landing_stats.py @@ -14,7 +14,9 @@ (нет оценок, нет истории цен, нет сделок) — строка в landing_stats просто не появляется, ручка её не отдаёт, фронт не рисует блок. Ноль здесь читался бы как измеренный ноль («ни одно объявление не снижало цену»), а это враньё другого -рода, чем отсутствие данных. +рода, чем отсутствие данных. Правило действует и на ВТОРОМ прогоне: пропавшая +метрика удаляется из таблицы (см. refresh_landing_stats), иначе она осталась бы +на витрине со старым computed_at и читалась бы как измеренная сегодня. ЧЕГО ЗДЕСЬ НЕТ И НЕ БУДЕТ ------------------------- @@ -188,6 +190,16 @@ _UPSERT_SQL = text(""" computed_at = EXCLUDED.computed_at """) +# Строки метрик, которых в СЕГОДНЯШНЕМ наборе нет, удаляются. Метрика исчезает +# из набора ровно тогда, когда у неё пропал вход (см. «нет входа — нет строки»), +# и оставленная строка продолжала бы отдаваться ручкой как обычная — со старым +# computed_at, который витрина не обязана читать. Удалённая метрика — блок, +# которого на странице нет; протухшая — блок с враньём. +_PRUNE_SQL = text(""" + DELETE FROM landing_stats + WHERE metric <> ALL(CAST(:kept AS text[])) +""") + def _num(value: Any) -> float | None: """Привести значение агрегата к float; None остаётся None. @@ -332,19 +344,32 @@ def refresh_landing_stats( Sync (вызывается scheduler-триггером в executor, как check_deals_freshness). `params` не используется — принимается ради единой сигнатуры обработчиков. - Пустой результат — НЕ ошибка прогона: на свежей базе метрик может не быть - ни одной, и падать в failed из-за этого значит завести шумный алерт там, где - система работает штатно. Строки при этом не трогаются: старый срез лучше - отсутствующего, а его возраст виден по computed_at. + Метрика, у которой пропал вход, СНИМАЕТСЯ с витрины, а не доживает со старым + computed_at: строки, которых нет в сегодняшнем наборе, удаляются в той же + транзакции. Иначе «нет входа — нет строки» действует только на первом + прогоне, а дальше отсутствие данных выглядит как данные — ручка отдаёт такую + строку неотличимо от свежей, и отличить её можно только сравнив computed_at с + соседями, чего фронт не делает. + + Пустой результат — НЕ ошибка прогона: на свежей базе метрик может не быть ни + одной, и падать в failed из-за этого значит завести шумный алерт там, где + система работает штатно. Но и чистка в этом случае НЕ выполняется: разом + отвалившиеся все входы — это признак поломки самого прогона (пустая/недоступная + база), а не пяти одновременных «данных больше нет», и стирать по такому + признаку всю витрину нельзя. Чистка ходит только с непустым набором, где + пропажу конкретной метрики видно на фоне посчитавшихся соседей. """ del params - counters: dict[str, int] = {"metrics_written": 0} + counters: dict[str, int] = {"metrics_written": 0, "metrics_removed": 0} try: runs_mod.update_heartbeat(db, run_id, counters) metrics = collect_landing_metrics(db) for row in metrics: db.execute(_UPSERT_SQL, row) + if metrics: + removed = db.execute(_PRUNE_SQL, {"kept": [m["metric"] for m in metrics]}) + counters["metrics_removed"] = int(removed.rowcount or 0) db.commit() counters["metrics_written"] = len(metrics) diff --git a/tradein-mvp/backend/tests/test_landing_stats.py b/tradein-mvp/backend/tests/test_landing_stats.py index 001635a1..f6b71772 100644 --- a/tradein-mvp/backend/tests/test_landing_stats.py +++ b/tradein-mvp/backend/tests/test_landing_stats.py @@ -61,9 +61,13 @@ PREFIX = "/api/public/mera" # контракта collect_landing_metrics (он же порядок метрик на витрине), и его # перестановка должна быть заметна. class _FakeSession: - def __init__(self, rows: list[Any]) -> None: + def __init__(self, rows: list[Any], *, prune_rowcount: int = 0) -> None: self._rows = list(rows) self.upserts: list[dict[str, Any]] = [] + # Чистка протухших метрик: пишем сюда параметры каждого DELETE, чтобы + # тест видел И факт вызова, И список оставляемых метрик. + self.prunes: list[dict[str, Any] | None] = [] + self._prune_rowcount = prune_rowcount self.committed = 0 # Считываем ТОЛЬКО запросы самой витрины: по этой же сессии ходит @@ -83,6 +87,9 @@ class _FakeSession: assert params is not None self.upserts.append(params) return MagicMock() + if "DELETE FROM landing_stats" in sql: + self.prunes.append(params) + return SimpleNamespace(rowcount=self._prune_rowcount) if not any(marker in sql for marker in self._METRIC_SQL_MARKERS): return MagicMock() assert self._rows, f"неожиданный лишний SELECT: {sql[:80]}" @@ -228,6 +235,44 @@ def test_refresh_upserts_every_metric_and_commits() -> None: } +def test_metric_that_stopped_computing_is_deleted_not_left_stale() -> None: + """Пропал вход у метрики — строка УДАЛЯЕТСЯ, а не доживает со старым + computed_at: иначе ручка отдаёт её неотличимо от посчитанной сегодня. + + Здесь сделок нет (`deals.n = 0`), значит `deals_total_12m` в наборе не + появляется — и именно её обязан вынести DELETE, оставив ровно посчитанные. + """ + rows = _rows(deals=SimpleNamespace(n=0)) + db = _FakeSession(rows, prune_rowcount=1) + counters = ls.refresh_landing_stats(db, run_id=1) # type: ignore[arg-type] + + assert len(db.prunes) == 1, "чистка протухших метрик не выполнена" + kept = set(db.prunes[0]["kept"]) # type: ignore[index] + assert kept == {u["metric"] for u in db.upserts} + assert "deals_total_12m" not in kept, "метрика без входа осталась бы на витрине" + assert counters["metrics_removed"] == 1 + + +def test_totally_empty_run_keeps_the_showcase_instead_of_wiping_it() -> None: + """Разом пропали ВСЕ входы — это похоже на поломку прогона (пустая или + недоступная база), а не на пять одновременных «данных больше нет». По такому + признаку витрина не стирается: DELETE не выполняется вовсе.""" + empty = _rows( + estimates=SimpleNamespace(total=0, period_days=None), + analogs=SimpleNamespace(n=0, median=None), + listing_age=SimpleNamespace(n=0, median=None), + price=SimpleNamespace(n=0, n_cut=0, median_pct_per_month=None), + deals=SimpleNamespace(n=0), + ) + db = _FakeSession(empty) + counters = ls.refresh_landing_stats(db, run_id=1) # type: ignore[arg-type] + + assert db.upserts == [] + assert db.prunes == [], "пустой прогон стёр бы всю витрину" + assert counters["metrics_written"] == 0 + assert counters["metrics_removed"] == 0 + + # ── 3. Границы выборки, которые исполняет Postgres ─────────────────────────── @@ -239,6 +284,44 @@ def test_price_sql_takes_domklik_only() -> None: assert "avito" not in sql and "yandex" not in sql +def test_price_sql_keeps_single_row_listings_in_denominator() -> None: + """Знаменатель доли снижений включает объявления с ОДНОЙ записью истории. + + Это тот самый дефект, из-за которого на проде получалось бы 84.8% вместо + 48.1%: у domklik триггер пишет стартовую цену, поэтому одна запись означает + «цену не менял» — наблюдение, а не отсутствие данных. Выкинув такие строки, + считаешь долю снижавших ТОЛЬКО среди менявших цену, то есть почти единицу. + + Гейт текстовый, а не прогон на живой базе: DATABASE_URL в CI — + заглушка (deploy-tradein.yml: `test:` job), Postgres в тестовой джобе нет, + и живой тест по образцу test_purge_expired_trade_in_data.py тут молча + скипался бы — то есть не гейтил бы ничего. Пин проверяет две половины + дефекта: (1) однострочные попадают в `moved` через ветку CASE со значением + 0 («не снижал»), а не отбрасываются; (2) нигде в запросе нет фильтра по + числу записей, который бы их отсёк. + """ + sql = str(ls._PRICE_MOVES_SQL) + + case = re.search(r"CASE\b(?P.*?)\bEND\b", sql, re.S | re.I) + assert case is not None, "исчезла ветка для однострочных — они больше не «не снижал»" + body = case.group("body") + assert "n_rows" in body, "ветка перестала различать однострочные записи истории" + assert re.search(r"\b(THEN|ELSE)\s+0\b", body), ( + "однострочным объявлениям больше не приписывается изменение 0% — " + "они либо выпали из выборки, либо получили выдуманное значение" + ) + + rest = sql.replace(case.group(0), "") + leftover = re.search(r"n_rows\s*(>=|>|<|<>|=|!=)", rest) + assert leftover is None, ( + f"появился фильтр по числу записей истории вне ветки CASE ({leftover.group(0)!r}) — " + "он выкидывает не менявших цену из знаменателя, доля вырастет с ~48% до ~85%" + ) + assert not re.search(r"\bHAVING\b", rest, re.I), ( + "HAVING в агрегате истории отсекает однострочные ещё до знаменателя" + ) + + def test_price_sql_keeps_span_and_outlier_gates() -> None: sql = str(ls._PRICE_MOVES_SQL) assert "span_days" in sql, "исчез порог наблюдения — короткоживущие дадут ложное «не снижал»" -- 2.45.3