repository.py 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
  1. """Funnel repository implementations.
  2. Two concrete sources behind the same :class:`FunnelRepository` protocol so route
  3. and service code is identical regardless of backing store:
  4. * :class:`SqlAlchemyFunnelRepository` - reads the two group-buy funnel tables
  5. (``ads_trd_group_funnel_daily`` + ``ads_trd_group_funnel_rolling``) from
  6. Postgres via an :class:`AsyncSession`.
  7. * :class:`FakeFunnelRepository` - realistic in-memory data (daily history + one
  8. rolling row) so the page works with zero infrastructure (``USE_FAKE_DATA=true``).
  9. """
  10. from __future__ import annotations
  11. from datetime import date, datetime, timedelta
  12. from sqlalchemy import select
  13. from sqlalchemy.ext.asyncio import AsyncSession
  14. from app.db.models import AdsTrdGroupFunnelDaily, AdsTrdGroupFunnelRolling
  15. from app.services.funnel import FunnelSnapshot
  16. # Daily table UV columns (funnel order).
  17. _DAILY_COLUMNS: tuple[str, ...] = (
  18. "uv_start",
  19. "uv_show",
  20. "uv_detail",
  21. "uv_order",
  22. "uv_paid",
  23. )
  24. # Rolling table UV columns (7d window then 30d window).
  25. _ROLLING_COLUMNS: tuple[str, ...] = (
  26. "uv_start_7d",
  27. "uv_show_7d",
  28. "uv_detail_7d",
  29. "uv_order_7d",
  30. "uv_paid_7d",
  31. "uv_start_30d",
  32. "uv_show_30d",
  33. "uv_detail_30d",
  34. "uv_order_30d",
  35. "uv_paid_30d",
  36. )
  37. class SqlAlchemyFunnelRepository:
  38. """Concrete repository backed by an :class:`AsyncSession` over Postgres."""
  39. def __init__(self, session: AsyncSession) -> None:
  40. self._session = session
  41. async def fetch_daily(self, dt: str | None) -> FunnelSnapshot | None:
  42. stmt = select(AdsTrdGroupFunnelDaily)
  43. if dt is not None:
  44. stmt = stmt.where(AdsTrdGroupFunnelDaily.dt == dt)
  45. else:
  46. # Latest day. dt is yyyyMMdd so lexical == chronological order.
  47. stmt = stmt.order_by(AdsTrdGroupFunnelDaily.dt.desc()).limit(1)
  48. row = (await self._session.execute(stmt)).scalars().first()
  49. if row is None:
  50. return None
  51. values = {col: getattr(row, col) for col in _DAILY_COLUMNS}
  52. return FunnelSnapshot(dt=row.dt, values=values)
  53. async def fetch_rolling(self) -> FunnelSnapshot | None:
  54. # Single-row table; order by dt desc + limit 1 is defensive.
  55. stmt = (
  56. select(AdsTrdGroupFunnelRolling)
  57. .order_by(AdsTrdGroupFunnelRolling.dt.desc())
  58. .limit(1)
  59. )
  60. row = (await self._session.execute(stmt)).scalars().first()
  61. if row is None:
  62. return None
  63. values = {col: getattr(row, col) for col in _ROLLING_COLUMNS}
  64. return FunnelSnapshot(dt=row.dt, values=values)
  65. # Cumulative keep-rate down the group-buy funnel: start, show, detail, order, paid.
  66. _FUNNEL_KEEP = [1.0, 0.82, 0.51, 0.22, 0.18]
  67. _STEP_KEYS = ["start", "show", "detail", "order", "paid"]
  68. def _funnel(top: int) -> list[int]:
  69. """Descending UVs for the five steps from the top-step UV."""
  70. return [max(0, int(top * keep)) for keep in _FUNNEL_KEEP]
  71. class FakeFunnelRepository:
  72. """In-memory repository with realistic group-buy funnel data, no DB needed.
  73. Used when no database is configured (``USE_FAKE_DATA=true``). Provides:
  74. * a span of ``HISTORY_DAYS`` daily rows ending yesterday, so the single-day
  75. date picker has history and different ``snapshot_dt`` values yield
  76. different data;
  77. * one rolling row as-of yesterday for the 7d/30d windows.
  78. A ``snapshot_dt`` with no matching daily row -> ``fetch_daily`` returns None,
  79. so the service reports ``missing``.
  80. """
  81. # Number of historical daily rows (ending yesterday).
  82. HISTORY_DAYS = 10
  83. # Top-step (start) daily UV for the most recent day; older days scale down.
  84. DAILY_TOP = 12000
  85. # Top-step UV for the rolling windows.
  86. ROLLING_TOP_7D = 74000
  87. ROLLING_TOP_30D = 295000
  88. def __init__(self) -> None:
  89. today = date.today()
  90. yesterday = today - timedelta(days=1)
  91. now = datetime.utcnow()
  92. # Build daily history: yyyyMMdd -> value map. Newest day = full size,
  93. # older days a touch smaller so rows differ.
  94. self._daily: dict[str, dict[str, int | None]] = {}
  95. for offset in range(self.HISTORY_DAYS):
  96. day = yesterday - timedelta(days=offset)
  97. dt = day.strftime("%Y%m%d")
  98. scale = 1.0 - 0.04 * offset
  99. top = int(self.DAILY_TOP * scale)
  100. self._daily[dt] = {
  101. col: uv for col, uv in zip(_DAILY_COLUMNS, _funnel(top))
  102. }
  103. self._latest_daily_dt = yesterday.strftime("%Y%m%d")
  104. # Rolling row, as-of yesterday.
  105. self._rolling_dt = yesterday.strftime("%Y%m%d")
  106. self._rolling: dict[str, int | None] = {}
  107. for col, uv in zip(_ROLLING_COLUMNS[:5], _funnel(self.ROLLING_TOP_7D)):
  108. self._rolling[col] = uv
  109. for col, uv in zip(_ROLLING_COLUMNS[5:], _funnel(self.ROLLING_TOP_30D)):
  110. self._rolling[col] = uv
  111. self._etl_time = now
  112. async def fetch_daily(self, dt: str | None) -> FunnelSnapshot | None:
  113. if dt is None:
  114. dt = self._latest_daily_dt
  115. values = self._daily.get(dt)
  116. if values is None:
  117. return None
  118. return FunnelSnapshot(dt=dt, values=dict(values))
  119. async def fetch_rolling(self) -> FunnelSnapshot | None:
  120. return FunnelSnapshot(dt=self._rolling_dt, values=dict(self._rolling))