data_source.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358
  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_recent_spend_accounts_sql(start_date: str, end_date: str) -> str:
  177. """Build the account scope query for three complete cost partitions."""
  178. parse_yyyymmdd(start_date)
  179. parse_yyyymmdd(end_date)
  180. return f"""
  181. SELECT
  182. 账号id AS account_id,
  183. MAX(NVL(账号名称, '')) AS account_name,
  184. SUM(NVL(成本, 0)) AS cost_yuan
  185. FROM {TABLE_NAME}
  186. WHERE dt BETWEEN '{start_date}' AND '{end_date}'
  187. AND 账号id IS NOT NULL
  188. AND NVL(账号id, '') <> ''
  189. GROUP BY 账号id
  190. HAVING SUM(NVL(成本, 0)) > 0
  191. ORDER BY account_id
  192. """.strip()
  193. def fetch_recent_spend_accounts(
  194. client: ODPSClient,
  195. start_date: str,
  196. end_date: str,
  197. ) -> list[dict[str, object]]:
  198. frame = client.execute_sql(
  199. build_recent_spend_accounts_sql(start_date, end_date)
  200. )
  201. accounts: list[dict[str, object]] = []
  202. for row in frame.to_dict(orient="records"):
  203. raw_account_id = row.get("account_id")
  204. try:
  205. account_id = int(raw_account_id)
  206. except (TypeError, ValueError):
  207. continue
  208. if account_id <= 0:
  209. continue
  210. accounts.append(
  211. {
  212. "account_id": account_id,
  213. "account_name": str(row.get("account_name") or ""),
  214. "cost_yuan": float(row.get("cost_yuan") or 0),
  215. }
  216. )
  217. return accounts
  218. def build_account_agency_fallback_sql(account_ids: list[int]) -> str:
  219. """Build the account-level agency fallback query for known numeric IDs."""
  220. normalized_ids: set[int] = set()
  221. for value in account_ids:
  222. try:
  223. account_id = int(value)
  224. except (TypeError, ValueError):
  225. continue
  226. if account_id > 0:
  227. normalized_ids.add(account_id)
  228. sorted_ids = sorted(normalized_ids)
  229. if not sorted_ids:
  230. raise ValueError("account_ids must contain at least one positive account ID")
  231. account_filter = ", ".join(f"'{value}'" for value in sorted_ids)
  232. return f"""
  233. SELECT account_id, account_name, agent_name
  234. FROM (
  235. SELECT
  236. account_id,
  237. account_name,
  238. agent_name,
  239. is_delete,
  240. status,
  241. ROW_NUMBER() OVER (
  242. PARTITION BY account_id
  243. ORDER BY id DESC
  244. ) AS row_number
  245. FROM loghubods.ad_put_tencent_account
  246. WHERE account_id IN ({account_filter})
  247. ) latest
  248. WHERE row_number = 1
  249. AND NVL(is_delete, 0) = 0
  250. AND status = 1
  251. AND agent_name IS NOT NULL
  252. AND TRIM(agent_name) <> ''
  253. """.strip()
  254. def fetch_account_agency_fallbacks(
  255. client: ODPSClient,
  256. account_ids: list[int],
  257. ) -> dict[int, str]:
  258. """Read each account's latest non-deleted, non-empty agency name."""
  259. if not account_ids:
  260. return {}
  261. frame = client.execute_sql(build_account_agency_fallback_sql(account_ids))
  262. agencies: dict[int, str] = {}
  263. for row in frame.to_dict(orient="records"):
  264. try:
  265. account_id = int(row.get("account_id"))
  266. except (TypeError, ValueError):
  267. continue
  268. raw_agency_name = row.get("agent_name")
  269. agency_name = (
  270. ""
  271. if raw_agency_name is None or pd.isna(raw_agency_name)
  272. else "".join(str(raw_agency_name).split())
  273. )
  274. if account_id > 0 and agency_name:
  275. agencies[account_id] = agency_name
  276. return agencies
  277. def build_ad_age_sql(end_date: str, lookback_days: int = 30) -> str:
  278. end = parse_yyyymmdd(end_date)
  279. start_date = (end - timedelta(days=lookback_days - 1)).strftime("%Y%m%d")
  280. return f"""
  281. SELECT
  282. 广告id,
  283. MIN(dt) AS 首次出现日期
  284. FROM {TABLE_NAME}
  285. WHERE dt BETWEEN '{start_date}' AND '{end_date}'
  286. AND usersharedepth = '0'
  287. AND channel = '{SELF_CHANNEL}'
  288. AND videoid IS NOT NULL
  289. AND NVL(hotsencetype, '') <> '1167'
  290. AND 广告id IS NOT NULL
  291. GROUP BY 广告id
  292. """.strip()
  293. def fetch_daily_data(
  294. client: ODPSClient,
  295. start_date: str,
  296. end_date: str,
  297. ) -> pd.DataFrame:
  298. return client.execute_sql(build_daily_sql(start_date, end_date))
  299. def fetch_ad_age(
  300. client: ODPSClient,
  301. end_date: str,
  302. lookback_days: int = 30,
  303. ) -> pd.DataFrame:
  304. raw = client.execute_sql(build_ad_age_sql(end_date, lookback_days))
  305. if raw.empty:
  306. return pd.DataFrame(columns=["广告id", "广告age"])
  307. end = parse_yyyymmdd(end_date)
  308. result = raw.copy()
  309. result["广告id"] = result["广告id"].fillna("").astype(str)
  310. first_seen = pd.to_datetime(result["首次出现日期"].astype(str), format="%Y%m%d")
  311. result["广告age"] = (end - first_seen).dt.days + 1
  312. return result[["广告id", "广告age"]]