feat(mera/b2c): метрики лэндинга считаются по проду, а не лежат литералами #3228
8 changed files with 1045 additions and 2 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) перед
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
397
tradein-mvp/backend/app/tasks/landing_stats.py
Normal file
397
tradein-mvp/backend/app/tasks/landing_stats.py
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
"""Пересчёт витринных метрик публичного лэндинга МЕРЫ (таблица landing_stats).
|
||||
|
||||
ЗАЧЕМ
|
||||
-----
|
||||
Числа на лэндинге (frontend/src/app/mera-public/marketing-v3.ts) были литералами
|
||||
— то есть придуманными. Публичная страница, которая продаёт «расчёт по данным»,
|
||||
не может показывать цифры, которых в данных нет: это ровно та подмена, против
|
||||
которой продукт и позиционируется. Здесь каждая витринная величина считается
|
||||
запросом к проду, и вместе с ней пишется размер выборки.
|
||||
|
||||
ГЛАВНОЕ ПРАВИЛО: НЕТ ВХОДА — НЕТ СТРОКИ
|
||||
---------------------------------------
|
||||
Ни одна метрика не пишется с подставленным значением. Если выборка пуста
|
||||
(нет оценок, нет истории цен, нет сделок) — строка в landing_stats просто не
|
||||
появляется, ручка её не отдаёт, фронт не рисует блок. Ноль здесь читался бы как
|
||||
измеренный ноль («ни одно объявление не снижало цену»), а это враньё другого
|
||||
рода, чем отсутствие данных. Правило действует и на ВТОРОМ прогоне: пропавшая
|
||||
метрика удаляется из таблицы (см. refresh_landing_stats), иначе она осталась бы
|
||||
на витрине со старым computed_at и читалась бы как измеренная сегодня.
|
||||
|
||||
ЧЕГО ЗДЕСЬ НЕТ И НЕ БУДЕТ
|
||||
-------------------------
|
||||
«Точность прогноза» и «срок продажи» — величин с такими именами в базе нет.
|
||||
Точность считает бэктест (своя задача, свои допущения), а срок продажи требует
|
||||
пары «объявление снято → сделка», которой у нас нет: снятие объявления не
|
||||
означает продажу. `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
|
||||
""")
|
||||
|
||||
# Строки метрик, которых в СЕГОДНЯШНЕМ наборе нет, удаляются. Метрика исчезает
|
||||
# из набора ровно тогда, когда у неё пропал вход (см. «нет входа — нет строки»),
|
||||
# и оставленная строка продолжала бы отдаваться ручкой как обычная — со старым
|
||||
# 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.
|
||||
|
||||
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` не используется — принимается ради единой сигнатуры обработчиков.
|
||||
|
||||
Метрика, у которой пропал вход, СНИМАЕТСЯ с витрины, а не доживает со старым
|
||||
computed_at: строки, которых нет в сегодняшнем наборе, удаляются в той же
|
||||
транзакции. Иначе «нет входа — нет строки» действует только на первом
|
||||
прогоне, а дальше отсутствие данных выглядит как данные — ручка отдаёт такую
|
||||
строку неотличимо от свежей, и отличить её можно только сравнив computed_at с
|
||||
соседями, чего фронт не делает.
|
||||
|
||||
Пустой результат — НЕ ошибка прогона: на свежей базе метрик может не быть ни
|
||||
одной, и падать в failed из-за этого значит завести шумный алерт там, где
|
||||
система работает штатно. Но и чистка в этом случае НЕ выполняется: разом
|
||||
отвалившиеся все входы — это признак поломки самого прогона (пустая/недоступная
|
||||
база), а не пяти одновременных «данных больше нет», и стирать по такому
|
||||
признаку всю витрину нельзя. Чистка ходит только с непустым набором, где
|
||||
пропажу конкретной метрики видно на фоне посчитавшихся соседей.
|
||||
"""
|
||||
del params
|
||||
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)
|
||||
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
|
||||
90
tradein-mvp/backend/data/sql/275_landing_stats.sql
Normal file
90
tradein-mvp/backend/data/sql/275_landing_stats.sql
Normal file
|
|
@ -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;
|
||||
470
tradein-mvp/backend/tests/test_landing_stats.py
Normal file
470
tradein-mvp/backend/tests/test_landing_stats.py
Normal file
|
|
@ -0,0 +1,470 @@
|
|||
"""Витринные метрики лэндинга — задача пересчёта + публичная ручка.
|
||||
|
||||
ЧТО ЗДЕСЬ ПРОВЕРЯЕТСЯ И ПОЧЕМУ ИМЕННО ЭТО
|
||||
|
||||
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], *, 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
|
||||
|
||||
# Считываем ТОЛЬКО запросы самой витрины: по этой же сессии ходит
|
||||
# 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 "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]}"
|
||||
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",
|
||||
}
|
||||
|
||||
|
||||
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 ───────────────────────────
|
||||
|
||||
|
||||
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_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<body>.*?)\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, "исчез порог наблюдения — короткоживущие дадут ложное «не снижал»"
|
||||
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}
|
||||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue