data_source.py 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. """ODPS 数据读取与日聚合 SQL。"""
  2. from __future__ import annotations
  3. from datetime import datetime, timedelta
  4. from typing import Tuple
  5. from zoneinfo import ZoneInfo
  6. import pandas as pd
  7. from .metrics import GZH_CHANNEL, SELF_CHANNEL
  8. from .odps_client import ODPSClient
  9. TABLE_NAME = "loghubods.opengid_base_data"
  10. SHANGHAI = ZoneInfo("Asia/Shanghai")
  11. class SourceDataNotReadyError(RuntimeError):
  12. """The exact ROI source partition required for this run is not ready."""
  13. def parse_yyyymmdd(value: str) -> datetime:
  14. try:
  15. return datetime.strptime(value, "%Y%m%d")
  16. except ValueError as exc:
  17. raise ValueError(f"日期必须是 YYYYMMDD,实际为: {value}") from exc
  18. def date_window(end_date: str) -> Tuple[str, str]:
  19. end = parse_yyyymmdd(end_date)
  20. return (end - timedelta(days=2)).strftime("%Y%m%d"), end.strftime("%Y%m%d")
  21. def build_source_readiness_sql(end_date: str) -> str:
  22. parse_yyyymmdd(end_date)
  23. return f"""
  24. SELECT
  25. COUNT(1) AS row_count,
  26. SUM(CASE WHEN channel = '{SELF_CHANNEL}' THEN 1 ELSE 0 END) AS self_rows,
  27. SUM(CASE WHEN channel = '{GZH_CHANNEL}' THEN 1 ELSE 0 END) AS gzh_rows
  28. FROM {TABLE_NAME}
  29. WHERE dt = '{end_date}'
  30. AND usersharedepth <= 1
  31. AND videoid IS NOT NULL
  32. AND NVL(hotsencetype, '') <> '1167'
  33. """.strip()
  34. def validate_source_ready(client: ODPSClient, end_date: str) -> dict[str, int]:
  35. """Require an exact, non-empty T-1 partition for every reported channel."""
  36. max_partition = client.odps.get_table("opengid_base_data").get_max_partition()
  37. if max_partition is None:
  38. raise SourceDataNotReadyError(
  39. f"ROI来源表没有可用分区: table={TABLE_NAME}, required_dt={end_date}"
  40. )
  41. latest = max_partition.partition_spec["dt"]
  42. parse_yyyymmdd(latest)
  43. if latest < end_date:
  44. raise SourceDataNotReadyError(
  45. f"ROI来源表未同步目标分区: table={TABLE_NAME}, "
  46. f"required_dt={end_date}, latest_dt={latest}"
  47. )
  48. frame = client.execute_sql(build_source_readiness_sql(end_date))
  49. if frame.empty:
  50. raise SourceDataNotReadyError(
  51. f"ROI来源表目标分区为空: table={TABLE_NAME}, dt={end_date}"
  52. )
  53. row = frame.iloc[0]
  54. counts = {}
  55. for name in ("row_count", "self_rows", "gzh_rows"):
  56. value = row.get(name)
  57. counts[name] = 0 if pd.isna(value) else int(value)
  58. missing = [name for name, value in counts.items() if value <= 0]
  59. if missing:
  60. raise SourceDataNotReadyError(
  61. f"ROI来源表目标分区业务数据未就绪: table={TABLE_NAME}, "
  62. f"dt={end_date}, missing={','.join(missing)}"
  63. )
  64. return counts
  65. def resolve_end_date(
  66. client: ODPSClient,
  67. requested: str | None = None,
  68. *,
  69. now: datetime | None = None,
  70. ) -> str:
  71. """Resolve exactly the requested date or T-1, then require source readiness."""
  72. if requested:
  73. parse_yyyymmdd(requested)
  74. target = requested
  75. else:
  76. current = now or datetime.now(SHANGHAI)
  77. if current.tzinfo is None:
  78. current = current.replace(tzinfo=SHANGHAI)
  79. target = (current.astimezone(SHANGHAI) - timedelta(days=1)).strftime(
  80. "%Y%m%d"
  81. )
  82. validate_source_ready(client, target)
  83. return target
  84. def build_daily_sql(start_date: str, end_date: str) -> str:
  85. parse_yyyymmdd(start_date)
  86. parse_yyyymmdd(end_date)
  87. common_filter = f"""
  88. dt BETWEEN '{start_date}' AND '{end_date}'
  89. AND usersharedepth <= 1
  90. AND videoid IS NOT NULL
  91. AND NVL(hotsencetype, '') <> '1167'
  92. """
  93. optimize_goal = (
  94. "CASE WHEN 广告优化目标 IS NULL OR 广告优化目标='' "
  95. "OR 广告优化目标='null' THEN '' ELSE 广告优化目标 END"
  96. )
  97. return f"""
  98. SELECT
  99. dt,
  100. 'self' AS entity_type,
  101. channel,
  102. MAX(NVL(代理名称, '')) AS 代理名称,
  103. NVL(账号id, '') AS 账号id,
  104. MAX(NVL(账号名称, '')) AS 账号名称,
  105. NVL(广告id, '') AS 广告id,
  106. MAX(NVL(广告名称, '')) AS 广告名称,
  107. NVL(包名, '') AS 包名,
  108. {optimize_goal} AS 广告优化目标,
  109. NVL(创意id, '') AS 创意id,
  110. '' AS 合作方名,
  111. '' AS 公众号名,
  112. COUNT(DISTINCT mid) AS 首层UV,
  113. SUM(NVL(t0_fission_uv_root, 0)) AS T0裂变数,
  114. SUM(NVL(成本, 0)) AS 成本,
  115. SUM(NVL(效率收入, 0)) AS 效率收入,
  116. SUM(NVL(裂变效率收入, 0)) AS 裂变效率收入
  117. FROM {TABLE_NAME}
  118. WHERE {common_filter}
  119. AND channel = '{SELF_CHANNEL}'
  120. AND 广告id IS NOT NULL
  121. AND 创意id IS NOT NULL
  122. GROUP BY
  123. dt, channel, 账号id, 广告id, 包名, {optimize_goal}, 创意id
  124. UNION ALL
  125. SELECT
  126. dt,
  127. 'self_ad' AS entity_type,
  128. channel,
  129. MAX(NVL(代理名称, '')) AS 代理名称,
  130. NVL(账号id, '') AS 账号id,
  131. MAX(NVL(账号名称, '')) AS 账号名称,
  132. NVL(广告id, '') AS 广告id,
  133. MAX(NVL(广告名称, '')) AS 广告名称,
  134. NVL(包名, '') AS 包名,
  135. {optimize_goal} AS 广告优化目标,
  136. '' AS 创意id,
  137. '' AS 合作方名,
  138. '' AS 公众号名,
  139. COUNT(DISTINCT mid) AS 首层UV,
  140. SUM(NVL(t0_fission_uv_root, 0)) AS T0裂变数,
  141. SUM(NVL(成本, 0)) AS 成本,
  142. SUM(NVL(效率收入, 0)) AS 效率收入,
  143. SUM(NVL(裂变效率收入, 0)) AS 裂变效率收入
  144. FROM {TABLE_NAME}
  145. WHERE {common_filter}
  146. AND channel = '{SELF_CHANNEL}'
  147. AND 广告id IS NOT NULL
  148. GROUP BY
  149. dt, channel, 账号id, 广告id, 包名, {optimize_goal}
  150. UNION ALL
  151. SELECT
  152. dt,
  153. 'gzh' AS entity_type,
  154. channel,
  155. '' AS 代理名称,
  156. '' AS 账号id,
  157. '' AS 账号名称,
  158. '' AS 广告id,
  159. '' AS 广告名称,
  160. '' AS 包名,
  161. '' AS 广告优化目标,
  162. '' AS 创意id,
  163. NVL(合作方名, '') AS 合作方名,
  164. NVL(公众号名, '') AS 公众号名,
  165. COUNT(DISTINCT mid) AS 首层UV,
  166. SUM(NVL(t0_fission_uv_root, 0)) AS T0裂变数,
  167. SUM(NVL(成本, 0)) AS 成本,
  168. SUM(NVL(效率收入, 0)) AS 效率收入,
  169. SUM(NVL(裂变效率收入, 0)) AS 裂变效率收入
  170. FROM {TABLE_NAME}
  171. WHERE {common_filter}
  172. AND channel = '{GZH_CHANNEL}'
  173. AND 公众号名 IS NOT NULL
  174. GROUP BY dt, channel, 合作方名, 公众号名
  175. """.strip()
  176. def build_ad_age_sql(end_date: str, lookback_days: int = 30) -> str:
  177. end = parse_yyyymmdd(end_date)
  178. start_date = (end - timedelta(days=lookback_days - 1)).strftime("%Y%m%d")
  179. return f"""
  180. SELECT
  181. 广告id,
  182. MIN(dt) AS 首次出现日期
  183. FROM {TABLE_NAME}
  184. WHERE dt BETWEEN '{start_date}' AND '{end_date}'
  185. AND usersharedepth = '0'
  186. AND channel = '{SELF_CHANNEL}'
  187. AND videoid IS NOT NULL
  188. AND NVL(hotsencetype, '') <> '1167'
  189. AND 广告id IS NOT NULL
  190. GROUP BY 广告id
  191. """.strip()
  192. def fetch_daily_data(
  193. client: ODPSClient,
  194. start_date: str,
  195. end_date: str,
  196. ) -> pd.DataFrame:
  197. return client.execute_sql(build_daily_sql(start_date, end_date))
  198. def fetch_ad_age(
  199. client: ODPSClient,
  200. end_date: str,
  201. lookback_days: int = 30,
  202. ) -> pd.DataFrame:
  203. raw = client.execute_sql(build_ad_age_sql(end_date, lookback_days))
  204. if raw.empty:
  205. return pd.DataFrame(columns=["广告id", "广告age"])
  206. end = parse_yyyymmdd(end_date)
  207. result = raw.copy()
  208. result["广告id"] = result["广告id"].fillna("").astype(str)
  209. first_seen = pd.to_datetime(result["首次出现日期"].astype(str), format="%Y%m%d")
  210. result["广告age"] = (end - first_seen).dt.days + 1
  211. return result[["广告id", "广告age"]]