funnel.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
  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. FunnelTrendPoint,
  23. FunnelTrendResponse,
  24. Period,
  25. )
  26. # Default trend window (days) when the request omits a range.
  27. DEFAULT_TREND_DAYS = 30
  28. # Fixed funnel order: 启动 -> 曝光 -> 拼团详情 -> 下单 -> 成功 (docs/03 §11).
  29. # Each entry: (event_key, Chinese display name).
  30. FUNNEL_STEPS: list[tuple[str, str]] = [
  31. ("start", "启动"),
  32. ("show", "曝光"),
  33. ("detail", "拼团详情"),
  34. ("order", "下单"),
  35. ("paid", "成功"),
  36. ]
  37. # period -> rolling-table column suffix (for last_7d / last_30d).
  38. ROLLING_SUFFIX: dict[Period, str] = {
  39. Period.last_7d: "7d",
  40. Period.last_30d: "30d",
  41. }
  42. def daily_columns() -> list[str]:
  43. """Return the five ``uv_{step}`` daily-table column names, in funnel order."""
  44. return [f"uv_{event_key}" for event_key, _ in FUNNEL_STEPS]
  45. def rolling_columns(period: Period) -> list[str]:
  46. """Return the five ``uv_{step}_{suffix}`` rolling-table columns for a period."""
  47. suffix = ROLLING_SUFFIX[period]
  48. return [f"uv_{event_key}_{suffix}" for event_key, _ in FUNNEL_STEPS]
  49. # --------------------------------------------------------------------------- #
  50. # Validation #
  51. # --------------------------------------------------------------------------- #
  52. class SnapshotDateError(ValueError):
  53. """Raised when ``snapshot_dt`` is today or in the future (data is T+1)."""
  54. def validate_snapshot_dt(snapshot_dt: date) -> None:
  55. """Reject a ``snapshot_dt`` later than yesterday.
  56. Data is T+1: today's data is not computed yet, so the max queryable day is
  57. always yesterday. today or future -> :class:`SnapshotDateError`.
  58. """
  59. yesterday = date.today() - timedelta(days=1)
  60. if snapshot_dt > yesterday:
  61. raise SnapshotDateError(
  62. f"snapshot_dt {snapshot_dt.isoformat()} is later than yesterday "
  63. f"({yesterday.isoformat()}); data is T+1 and today's data is not "
  64. "computed yet."
  65. )
  66. class TrendRangeError(ValueError):
  67. """Raised when a trend range is invalid (end in the future, or start > end)."""
  68. def validate_trend_range(start_dt: date | None, end_dt: date | None) -> None:
  69. """Reject an out-of-range trend window (data is T+1; bounds must be ordered)."""
  70. yesterday = date.today() - timedelta(days=1)
  71. if end_dt is not None and end_dt > yesterday:
  72. raise TrendRangeError(
  73. f"end_dt {end_dt.isoformat()} is later than yesterday "
  74. f"({yesterday.isoformat()}); data is T+1."
  75. )
  76. if start_dt is not None and end_dt is not None and start_dt > end_dt:
  77. raise TrendRangeError(
  78. f"start_dt {start_dt.isoformat()} is after end_dt {end_dt.isoformat()}."
  79. )
  80. # --------------------------------------------------------------------------- #
  81. # Repository protocol + snapshot container #
  82. # --------------------------------------------------------------------------- #
  83. @dataclass(frozen=True)
  84. class FunnelSnapshot:
  85. """A funnel source row as returned by the repository.
  86. ``values`` maps column name to its value (NULL columns map to ``None``).
  87. ``dt`` is the row's ``dt`` (yyyyMMdd): the day for daily, the as-of day for
  88. rolling.
  89. """
  90. dt: str
  91. values: dict[str, int | None]
  92. class FunnelRepository(Protocol):
  93. """Data access boundary. Implementations may hit Postgres or be in-memory."""
  94. async def fetch_daily(self, dt: str | None) -> FunnelSnapshot | None:
  95. """Return a row from the daily table.
  96. ``dt`` (yyyyMMdd) selects that specific day; ``None`` returns the latest
  97. ``dt`` row. ``None`` when no matching row exists.
  98. """
  99. ...
  100. async def fetch_rolling(self) -> FunnelSnapshot | None:
  101. """Return the single rolling row, or ``None`` when the table is empty."""
  102. ...
  103. async def fetch_daily_range(
  104. self, start_dt: str, end_dt: str
  105. ) -> list[FunnelSnapshot]:
  106. """Return daily rows with ``start_dt <= dt <= end_dt`` (yyyyMMdd), ascending.
  107. Empty list when no rows fall in the range.
  108. """
  109. ...
  110. # --------------------------------------------------------------------------- #
  111. # Conversion math #
  112. # --------------------------------------------------------------------------- #
  113. def build_results(uvs: list[int]) -> list[FunnelStepResult]:
  114. """Assemble per-step results with conversion/dropoff math.
  115. Step 1: conversion_rate and dropoff_rate are null.
  116. Step i: conversion = uv[i] / uv[i-1]; dropoff = 1 - conversion.
  117. If uv[i-1] == 0, conversion (and dropoff) are null.
  118. """
  119. results: list[FunnelStepResult] = []
  120. for idx, (uv, (event_key, name)) in enumerate(zip(uvs, FUNNEL_STEPS)):
  121. if idx == 0:
  122. conversion: float | None = None
  123. dropoff: float | None = None
  124. else:
  125. prev = uvs[idx - 1]
  126. if prev == 0:
  127. conversion = None
  128. dropoff = None
  129. else:
  130. conversion = uv / prev
  131. dropoff = 1 - conversion
  132. results.append(
  133. FunnelStepResult(
  134. step_index=idx + 1,
  135. name=name,
  136. event_key=event_key,
  137. uv=uv,
  138. conversion_rate=conversion,
  139. dropoff_rate=dropoff,
  140. )
  141. )
  142. return results
  143. # --------------------------------------------------------------------------- #
  144. # Orchestration #
  145. # --------------------------------------------------------------------------- #
  146. def _assemble(
  147. period: Period,
  148. snapshot: FunnelSnapshot | None,
  149. column_names: list[str],
  150. ) -> FunnelQueryResponse:
  151. """Map a fetched snapshot + period columns to the response model.
  152. Missing row or any NULL period column -> ``data_status = missing`` with no
  153. zero-fill. ``snapshot_dt`` echoes the row used (None when no row).
  154. """
  155. if snapshot is None:
  156. return FunnelQueryResponse(
  157. period=period,
  158. snapshot_dt=None,
  159. results=[],
  160. data_status=DataStatus.missing,
  161. )
  162. raw = [snapshot.values.get(col) for col in column_names]
  163. if any(value is None for value in raw):
  164. return FunnelQueryResponse(
  165. period=period,
  166. snapshot_dt=snapshot.dt,
  167. results=[],
  168. data_status=DataStatus.missing,
  169. )
  170. uvs = [int(value) for value in raw] # narrow Optional -> int
  171. return FunnelQueryResponse(
  172. period=period,
  173. snapshot_dt=snapshot.dt,
  174. results=build_results(uvs),
  175. data_status=DataStatus.ready,
  176. )
  177. async def run_funnel_query(
  178. period: Period,
  179. repo: FunnelRepository,
  180. snapshot_dt: date | None = None,
  181. ) -> FunnelQueryResponse:
  182. """Run the group-buy funnel query pipeline and return the response model.
  183. Routes by ``period``:
  184. * ``day`` -> daily table; validates ``snapshot_dt`` (<= yesterday) when given,
  185. then selects that day or the latest row.
  186. * ``last_7d`` / ``last_30d`` -> the single rolling row; ``snapshot_dt`` is
  187. ignored.
  188. """
  189. if period is Period.day:
  190. dt_str: str | None = None
  191. if snapshot_dt is not None:
  192. validate_snapshot_dt(snapshot_dt) # may raise SnapshotDateError
  193. dt_str = snapshot_dt.strftime("%Y%m%d")
  194. snapshot = await repo.fetch_daily(dt_str)
  195. return _assemble(period, snapshot, daily_columns())
  196. # last_7d / last_30d -> rolling row; snapshot_dt ignored.
  197. snapshot = await repo.fetch_rolling()
  198. return _assemble(period, snapshot, rolling_columns(period))
  199. # --------------------------------------------------------------------------- #
  200. # Trend orchestration (按日折线) #
  201. # --------------------------------------------------------------------------- #
  202. def _parse_dt(dt: str) -> date:
  203. """Parse a yyyyMMdd string to a ``date``."""
  204. return date(int(dt[:4]), int(dt[4:6]), int(dt[6:8]))
  205. def _empty_trend() -> FunnelTrendResponse:
  206. return FunnelTrendResponse(
  207. start_dt=None, end_dt=None, data_status=DataStatus.missing, points=[]
  208. )
  209. def _build_trend_points(snapshots: list[FunnelSnapshot]) -> list[FunnelTrendPoint]:
  210. """Map daily snapshots -> trend points, dropping null-bearing days, then
  211. trimming LEADING all-zero (pre-launch) days.
  212. Conversion math reuses :func:`build_results` so the口径 matches the funnel
  213. query exactly. Days with any NULL uv column are omitted (a calendar gap the
  214. client renders as a line break); the daily table has no NULLs in practice.
  215. Middle/trailing zero days are kept (real zero activity, not pre-launch).
  216. """
  217. cols = daily_columns()
  218. points: list[FunnelTrendPoint] = []
  219. for snap in sorted(snapshots, key=lambda s: s.dt):
  220. raw = [snap.values.get(col) for col in cols]
  221. if any(value is None for value in raw):
  222. continue
  223. points.append(
  224. FunnelTrendPoint(dt=snap.dt, results=build_results([int(v) for v in raw]))
  225. )
  226. first = 0
  227. while first < len(points) and points[first].results[0].uv == 0:
  228. first += 1
  229. return points[first:]
  230. async def run_funnel_trend(
  231. repo: FunnelRepository,
  232. start_dt: date | None = None,
  233. end_dt: date | None = None,
  234. ) -> FunnelTrendResponse:
  235. """Run the按日 trend pipeline over the daily table and return the series.
  236. Range resolution:
  237. * ``end`` = ``end_dt`` (capped at yesterday) if given, else the latest
  238. available daily ``dt``.
  239. * ``start`` = ``start_dt`` if given, else ``end`` - (DEFAULT_TREND_DAYS - 1).
  240. Leading pre-launch (all-zero) days are trimmed; the response bounds reflect
  241. the kept series. No rows / all-zero -> ``missing``.
  242. """
  243. validate_trend_range(start_dt, end_dt) # may raise TrendRangeError
  244. yesterday = date.today() - timedelta(days=1)
  245. if end_dt is not None:
  246. end = min(end_dt, yesterday)
  247. else:
  248. latest = await repo.fetch_daily(None)
  249. if latest is None:
  250. return _empty_trend()
  251. end = _parse_dt(latest.dt)
  252. start = start_dt if start_dt is not None else end - timedelta(days=DEFAULT_TREND_DAYS - 1)
  253. if start > end:
  254. return _empty_trend()
  255. snapshots = await repo.fetch_daily_range(
  256. start.strftime("%Y%m%d"), end.strftime("%Y%m%d")
  257. )
  258. points = _build_trend_points(snapshots)
  259. if not points:
  260. return _empty_trend()
  261. return FunnelTrendResponse(
  262. start_dt=points[0].dt,
  263. end_dt=points[-1].dt,
  264. data_status=DataStatus.ready,
  265. points=points,
  266. )