funnel.py 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  1. """拼团 (group-buy) funnel query orchestration (docs/02 §5/§6 v3).
  2. Pipeline: validate the request -> route by ``period`` to the right table/row ->
  3. pick the five UV columns -> derive conversion/dropoff -> resolve data_status ->
  4. assemble response.
  5. Routing (docs/02 §6 v3):
  6. * ``day`` -> ``ads_trd_group_funnel_daily``; a given ``snapshot_dt`` selects
  7. that row, else the latest ``dt``. Columns: ``uv_{step}``.
  8. * ``last_7d`` -> ``ads_trd_group_funnel_rolling`` (single row). Columns: ``uv_{step}_7d``.
  9. * ``last_30d`` -> same rolling row. Columns: ``uv_{step}_30d``.
  10. No bitmaps, no OR, no cross-day aggregation. The repository layer is an abstract
  11. protocol so the service can be unit-tested without Postgres (and so a fake
  12. in-memory source can stand in when no DB is configured).
  13. """
  14. from __future__ import annotations
  15. from dataclasses import dataclass
  16. from datetime import date, timedelta
  17. from typing import Protocol
  18. from app.schemas import (
  19. DataStatus,
  20. FunnelQueryResponse,
  21. FunnelStepResult,
  22. Period,
  23. )
  24. # Fixed funnel order: 启动 -> 曝光 -> 拼团详情 -> 下单 -> 成功 (docs/03 §11).
  25. # Each entry: (event_key, Chinese display name).
  26. FUNNEL_STEPS: list[tuple[str, str]] = [
  27. ("start", "启动"),
  28. ("show", "曝光"),
  29. ("detail", "拼团详情"),
  30. ("order", "下单"),
  31. ("paid", "成功"),
  32. ]
  33. # period -> rolling-table column suffix (for last_7d / last_30d).
  34. ROLLING_SUFFIX: dict[Period, str] = {
  35. Period.last_7d: "7d",
  36. Period.last_30d: "30d",
  37. }
  38. def daily_columns() -> list[str]:
  39. """Return the five ``uv_{step}`` daily-table column names, in funnel order."""
  40. return [f"uv_{event_key}" for event_key, _ in FUNNEL_STEPS]
  41. def rolling_columns(period: Period) -> list[str]:
  42. """Return the five ``uv_{step}_{suffix}`` rolling-table columns for a period."""
  43. suffix = ROLLING_SUFFIX[period]
  44. return [f"uv_{event_key}_{suffix}" for event_key, _ in FUNNEL_STEPS]
  45. # --------------------------------------------------------------------------- #
  46. # Validation #
  47. # --------------------------------------------------------------------------- #
  48. class SnapshotDateError(ValueError):
  49. """Raised when ``snapshot_dt`` is today or in the future (data is T+1)."""
  50. def validate_snapshot_dt(snapshot_dt: date) -> None:
  51. """Reject a ``snapshot_dt`` later than yesterday.
  52. Data is T+1: today's data is not computed yet, so the max queryable day is
  53. always yesterday. today or future -> :class:`SnapshotDateError`.
  54. """
  55. yesterday = date.today() - timedelta(days=1)
  56. if snapshot_dt > yesterday:
  57. raise SnapshotDateError(
  58. f"snapshot_dt {snapshot_dt.isoformat()} is later than yesterday "
  59. f"({yesterday.isoformat()}); data is T+1 and today's data is not "
  60. "computed yet."
  61. )
  62. # --------------------------------------------------------------------------- #
  63. # Repository protocol + snapshot container #
  64. # --------------------------------------------------------------------------- #
  65. @dataclass(frozen=True)
  66. class FunnelSnapshot:
  67. """A funnel source row as returned by the repository.
  68. ``values`` maps column name to its value (NULL columns map to ``None``).
  69. ``dt`` is the row's ``dt`` (yyyyMMdd): the day for daily, the as-of day for
  70. rolling.
  71. """
  72. dt: str
  73. values: dict[str, int | None]
  74. class FunnelRepository(Protocol):
  75. """Data access boundary. Implementations may hit Postgres or be in-memory."""
  76. async def fetch_daily(self, dt: str | None) -> FunnelSnapshot | None:
  77. """Return a row from the daily table.
  78. ``dt`` (yyyyMMdd) selects that specific day; ``None`` returns the latest
  79. ``dt`` row. ``None`` when no matching row exists.
  80. """
  81. ...
  82. async def fetch_rolling(self) -> FunnelSnapshot | None:
  83. """Return the single rolling row, or ``None`` when the table is empty."""
  84. ...
  85. # --------------------------------------------------------------------------- #
  86. # Conversion math #
  87. # --------------------------------------------------------------------------- #
  88. def build_results(uvs: list[int]) -> list[FunnelStepResult]:
  89. """Assemble per-step results with conversion/dropoff math.
  90. Step 1: conversion_rate and dropoff_rate are null.
  91. Step i: conversion = uv[i] / uv[i-1]; dropoff = 1 - conversion.
  92. If uv[i-1] == 0, conversion (and dropoff) are null.
  93. """
  94. results: list[FunnelStepResult] = []
  95. for idx, (uv, (event_key, name)) in enumerate(zip(uvs, FUNNEL_STEPS)):
  96. if idx == 0:
  97. conversion: float | None = None
  98. dropoff: float | None = None
  99. else:
  100. prev = uvs[idx - 1]
  101. if prev == 0:
  102. conversion = None
  103. dropoff = None
  104. else:
  105. conversion = uv / prev
  106. dropoff = 1 - conversion
  107. results.append(
  108. FunnelStepResult(
  109. step_index=idx + 1,
  110. name=name,
  111. event_key=event_key,
  112. uv=uv,
  113. conversion_rate=conversion,
  114. dropoff_rate=dropoff,
  115. )
  116. )
  117. return results
  118. # --------------------------------------------------------------------------- #
  119. # Orchestration #
  120. # --------------------------------------------------------------------------- #
  121. def _assemble(
  122. period: Period,
  123. snapshot: FunnelSnapshot | None,
  124. column_names: list[str],
  125. ) -> FunnelQueryResponse:
  126. """Map a fetched snapshot + period columns to the response model.
  127. Missing row or any NULL period column -> ``data_status = missing`` with no
  128. zero-fill. ``snapshot_dt`` echoes the row used (None when no row).
  129. """
  130. if snapshot is None:
  131. return FunnelQueryResponse(
  132. period=period,
  133. snapshot_dt=None,
  134. results=[],
  135. data_status=DataStatus.missing,
  136. )
  137. raw = [snapshot.values.get(col) for col in column_names]
  138. if any(value is None for value in raw):
  139. return FunnelQueryResponse(
  140. period=period,
  141. snapshot_dt=snapshot.dt,
  142. results=[],
  143. data_status=DataStatus.missing,
  144. )
  145. uvs = [int(value) for value in raw] # narrow Optional -> int
  146. return FunnelQueryResponse(
  147. period=period,
  148. snapshot_dt=snapshot.dt,
  149. results=build_results(uvs),
  150. data_status=DataStatus.ready,
  151. )
  152. async def run_funnel_query(
  153. period: Period,
  154. repo: FunnelRepository,
  155. snapshot_dt: date | None = None,
  156. ) -> FunnelQueryResponse:
  157. """Run the group-buy funnel query pipeline and return the response model.
  158. Routes by ``period``:
  159. * ``day`` -> daily table; validates ``snapshot_dt`` (<= yesterday) when given,
  160. then selects that day or the latest row.
  161. * ``last_7d`` / ``last_30d`` -> the single rolling row; ``snapshot_dt`` is
  162. ignored.
  163. """
  164. if period is Period.day:
  165. dt_str: str | None = None
  166. if snapshot_dt is not None:
  167. validate_snapshot_dt(snapshot_dt) # may raise SnapshotDateError
  168. dt_str = snapshot_dt.strftime("%Y%m%d")
  169. snapshot = await repo.fetch_daily(dt_str)
  170. return _assemble(period, snapshot, daily_columns())
  171. # last_7d / last_30d -> rolling row; snapshot_dt ignored.
  172. snapshot = await repo.fetch_rolling()
  173. return _assemble(period, snapshot, rolling_columns(period))