gendesign/tradein-mvp/backend/app/tasks/listing_source_snapshot.py
Light1YT e925a0ba6a
All checks were successful
Deploy Trade-In / changes (push) Successful in 6s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 45s
Deploy Trade-In / deploy (push) Successful in 35s
feat(tradein): per-source listing price history — schema + daily snapshot (#570) (#646)
2026-05-29 12:18:04 +00:00

141 lines
6.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""Daily per-source snapshot writer (#570).
Берёт текущее состояние listing_sources (последний снимок на canonical listing × source)
и пишет ежедневный ряд в listing_source_snapshots + change-log price_change в
listing_source_events. Так история per-source цены копится СРАЗУ, независимо от
(сейчас DORMANT) скраперов — см. шапку data/sql/079_listing_source_history.sql.
Задача синхронная (DB-only, никаких внешних HTTP-вызовов) — запускается in-app
scheduler'ом через trigger_listing_source_snapshot_run() (scheduler.py),
по образцу import_rosreestr_dkp (sync task в run_in_executor).
Вся работа — два set-based SQL statement'а (snapshot upsert + event-diff CTE),
никакого row-by-row Python: 18 355 строк обслуживаются одним INSERT … SELECT каждый.
"""
from __future__ import annotations
import logging
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.services import scrape_runs as runs_mod
logger = logging.getLogger(__name__)
# Окно свежести: источник считается активным, если last_seen_at не старше N дней.
FRESHNESS_WINDOW_DAYS = 7
# ── Daily snapshot upsert ─────────────────────────────────────────────────────
# Снимок на (listing_source_id, CURRENT_DATE). ON CONFLICT → last-write-wins за день
# (повторный прогон в те же сутки перезаписывает снимок свежими значениями).
# is_active derived: last_seen_at в пределах окна свежести на момент снимка.
# payload_hash = md5(raw_payload::text) — ::text на колонке допустим (это не bind-param).
# run_id через CAST(:run_id AS bigint) — psycopg v3 (никогда :run_id::bigint).
_SNAPSHOT_SQL = text(
"""
INSERT INTO listing_source_snapshots (
listing_source_id, snapshot_date, price_rub, is_active,
last_seen_at, payload_hash, observed_at, run_id
)
SELECT
id,
CURRENT_DATE,
price_rub,
(last_seen_at > now() - make_interval(days => :freshness_days)) AS is_active,
last_seen_at,
md5(raw_payload::text),
now(),
CAST(:run_id AS bigint)
FROM listing_sources
ON CONFLICT (listing_source_id, snapshot_date) DO UPDATE SET
price_rub = EXCLUDED.price_rub,
is_active = EXCLUDED.is_active,
last_seen_at = EXCLUDED.last_seen_at,
payload_hash = EXCLUDED.payload_hash,
observed_at = EXCLUDED.observed_at,
run_id = EXCLUDED.run_id
"""
)
# ── Event diff: price_change ──────────────────────────────────────────────────
# Для каждого источника сравниваем сегодняшнюю цену (snapshot_date = CURRENT_DATE) с
# самым свежим ПРЕДЫДУЩИМ снимком (snapshot_date < CURRENT_DATE). Если цена изменилась
# (обе NOT NULL, old <> 0) — пишем price_change.
# today — снимок за сегодня (только что записан _SNAPSHOT_SQL).
# prior — последний снимок строго ДО сегодня (DISTINCT ON … ORDER BY date DESC).
# Полностью set-based: один INSERT … SELECT по всем источникам, без Python-цикла.
# change_time = now() детерминирует UNIQUE(listing_source_id, change_time, event_type)
# в пределах прогона → ON CONFLICT DO NOTHING делает писатель идемпотентным.
_EVENT_DIFF_SQL = text(
"""
WITH today AS (
SELECT listing_source_id, price_rub
FROM listing_source_snapshots
WHERE snapshot_date = CURRENT_DATE
),
prior AS (
SELECT DISTINCT ON (listing_source_id)
listing_source_id, price_rub
FROM listing_source_snapshots
WHERE snapshot_date < CURRENT_DATE
ORDER BY listing_source_id, snapshot_date DESC
)
INSERT INTO listing_source_events (
listing_source_id, change_time, event_type, price_rub, diff_percent
)
SELECT
t.listing_source_id,
now(),
'price_change',
t.price_rub,
round((t.price_rub - p.price_rub)::numeric / p.price_rub * 100, 4)
FROM today t
JOIN prior p ON p.listing_source_id = t.listing_source_id
WHERE t.price_rub IS NOT NULL
AND p.price_rub IS NOT NULL
AND p.price_rub <> 0
AND t.price_rub <> p.price_rub
ON CONFLICT (listing_source_id, change_time, event_type) DO NOTHING
"""
)
def snapshot_listing_sources(db: Session, run_id: int) -> dict[str, int]:
"""Записать дневной снимок listing_sources + price_change-события.
Sync (вызывается scheduler-триггером в executor, как import_rosreestr_dkp).
Два set-based statement'а в одной транзакции:
1. upsert снимка на (listing_source_id, CURRENT_DATE) — last-write-wins.
2. diff сегодняшней цены против последнего предыдущего снимка → price_change-события.
Финализирует scrape_runs (mark_done / mark_failed) и пишет counters.
Returns {"snapshotted": N, "price_change_events": M}.
"""
counters: dict[str, int] = {"snapshotted": 0, "price_change_events": 0}
try:
snap_result = db.execute(
_SNAPSHOT_SQL,
{"freshness_days": FRESHNESS_WINDOW_DAYS, "run_id": run_id},
)
counters["snapshotted"] = snap_result.rowcount or 0
event_result = db.execute(_EVENT_DIFF_SQL)
counters["price_change_events"] = event_result.rowcount or 0
db.commit()
runs_mod.mark_done(db, run_id, counters)
logger.info(
"snapshot_listing_sources run_id=%d done: snapshotted=%d price_change_events=%d",
run_id,
counters["snapshotted"],
counters["price_change_events"],
)
return counters
except Exception as exc:
logger.exception("snapshot_listing_sources run_id=%d failed", run_id)
db.rollback()
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters)
raise