im_approval_ad_creation.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. """模块 A 广告新建审批 — 飞书 sheet 生成 + 发送 + 轮询(P1-G,2026-06-09)。
  2. 数据流(Phase 0 模块 A):
  3. ad_creation.enumerate_new_ad_candidates 准备 N 条 AdCandidate (无定投 + 有定投)
  4. → generate_ad_approval_xlsx 生成 9 列 xlsx(hyperlink/下拉/颜色/冻结)
  5. → send_ad_approval_to_feishu 上传转飞书 sheet + 发卡片到群
  6. → poll_ad_approval_actions 轮询读"决策"列 → 拿 approve/reject/hold
  7. → 调用方按 row_idx 匹配 candidate 写决策
  8. 跟创意审批 (im_approval_creation) 对偶,但维度是"广告"而非"创意":
  9. - 列展示广告级元数据(版位 / 定投场景 / 出价 / 年龄)
  10. - 中文场景名通过 scene_spec 缓存查询
  11. """
  12. import asyncio
  13. import logging
  14. import time
  15. from pathlib import Path
  16. import httpx
  17. from openpyxl import Workbook
  18. from openpyxl.styles import Alignment, Font, PatternFill
  19. from openpyxl.worksheet.datavalidation import DataValidation
  20. from config import (
  21. CREATION_APPROVAL_TIMEOUT_MINUTES,
  22. FEISHU_OPERATOR_CHAT_ID,
  23. now_in_timezone,
  24. )
  25. from tools.feishu_doc import (
  26. _auth_headers,
  27. _get_tenant_token,
  28. import_to_feishu,
  29. )
  30. from tools.scene_spec import describe_position_ids
  31. from tools.delivery_config import BID_MODE_MAX_CONVERSION
  32. logger = logging.getLogger(__name__)
  33. FEISHU_BASE_URL = "https://open.feishu.cn/open-apis"
  34. # 11 列(中文)
  35. HEADERS = [
  36. # A 浅灰 4 列
  37. "日期", "账户ID", "人群包", "广告名称",
  38. # B 浅紫 6 列(广告维度)
  39. "投放版位", "版位定投场景", "出价方式", "出价(元)", "智能版位", "年龄定向",
  40. # C 浅绿 1 列
  41. "决策",
  42. ]
  43. GROUP_COLORS = [
  44. ((1, 4), "FFD9D9D9"), # A 浅灰
  45. ((5, 10), "FFD9C8E8"), # B 浅紫
  46. ((11, 11), "FFC6E0B4"), # C 浅绿
  47. ]
  48. COL_WIDTHS = {
  49. "A": 12, "B": 14, "C": 16, "D": 32,
  50. "E": 36, "F": 50, "G": 18, "H": 10,
  51. "I": 12, "J": 12, "K": 14,
  52. }
  53. DECISION_COL_LETTER = "K" # 第 11 列
  54. VALID_ACTIONS = ("approve", "reject", "hold")
  55. def _color_for_col(col_idx: int) -> str:
  56. """按列号返回所属分组的 header 背景色(ARGB),未匹配时返回白色。"""
  57. for (lo, hi), color in GROUP_COLORS:
  58. if lo <= col_idx <= hi:
  59. return color
  60. return "FFFFFFFF"
  61. def _format_site_set_cn(site_set: list) -> str:
  62. """把版位枚举列表转成中文名称,用顿号拼接(未知枚举原样保留)。"""
  63. cn_map = {
  64. "SITE_SET_WECHAT": "微信公众号",
  65. "SITE_SET_WECHAT_PLUGIN": "微信插件",
  66. "SITE_SET_SEARCH_SCENE": "搜索场景",
  67. "SITE_SET_MOMENTS": "朋友圈",
  68. "SITE_SET_MINI_GAME_WECHAT": "小游戏",
  69. "SITE_SET_MINI_PROGRAM_WECHAT": "小程序",
  70. }
  71. return "、".join(cn_map.get(s, s) for s in (site_set or []))
  72. def _format_record_to_row(rec: dict) -> list:
  73. """rec 是 ad_creation.AdCandidate 的 dict 形式 + 加 approval_date / audience_tier / age_range 等"""
  74. automatic_site_enabled = bool(rec.get("automatic_site_enabled"))
  75. site_set_cn = "AIM+" if automatic_site_enabled else _format_site_set_cn(rec.get("site_set") or [])
  76. wp_ids = rec.get("wechat_position") or []
  77. wp_cn = describe_position_ids(int(rec["account_id"]), wp_ids)
  78. bid_yuan = f"{rec['bid_amount_fen'] / 100:.2f}" if rec.get("bid_amount_fen") else ""
  79. bid_scene_cn = (
  80. "最大转化量"
  81. if rec.get("bid_scene") == BID_MODE_MAX_CONVERSION
  82. else "平均成本"
  83. )
  84. return [
  85. rec["approval_date"],
  86. str(rec["account_id"]),
  87. rec.get("audience_tier_label", ""),
  88. rec.get("adgroup_name", ""),
  89. site_set_cn,
  90. wp_cn,
  91. bid_scene_cn,
  92. bid_yuan,
  93. "是" if automatic_site_enabled else "否",
  94. rec.get("age_range", ""),
  95. "", # 决策列空,等运营填
  96. ]
  97. def generate_ad_approval_xlsx(records: list[dict], output_path: Path) -> Path:
  98. """生成 9 列模块 A 广告新建待审批 xlsx。"""
  99. wb = Workbook()
  100. ws = wb.active
  101. ws.title = "广告新建审批"
  102. # header
  103. for col_idx, name in enumerate(HEADERS, start=1):
  104. cell = ws.cell(row=1, column=col_idx, value=name)
  105. cell.font = Font(bold=True, size=11)
  106. cell.alignment = Alignment(horizontal="center", vertical="center")
  107. cell.fill = PatternFill(
  108. start_color=_color_for_col(col_idx),
  109. end_color=_color_for_col(col_idx),
  110. fill_type="solid",
  111. )
  112. # 数据行
  113. for row_idx, rec in enumerate(records, start=2):
  114. row = _format_record_to_row(rec)
  115. for col_idx, val in enumerate(row, start=1):
  116. cell = ws.cell(row=row_idx, column=col_idx, value=val)
  117. cell.alignment = Alignment(
  118. horizontal="center", vertical="center", wrap_text=True,
  119. )
  120. # 决策列下拉
  121. dv = DataValidation(
  122. type="list",
  123. formula1='"approve,reject,hold"',
  124. allow_blank=True,
  125. )
  126. dv.error = "请选择 approve / reject / hold"
  127. ws.add_data_validation(dv)
  128. last_row = max(100, len(records) + 1)
  129. dv.add(f"{DECISION_COL_LETTER}2:{DECISION_COL_LETTER}{last_row}")
  130. # 冻结首行
  131. ws.freeze_panes = "A2"
  132. # 列宽 + 行高
  133. for col, w in COL_WIDTHS.items():
  134. ws.column_dimensions[col].width = w
  135. ws.row_dimensions[1].height = 28
  136. for r in range(2, 2 + len(records)):
  137. ws.row_dimensions[r].height = 80
  138. output_path.parent.mkdir(parents=True, exist_ok=True)
  139. wb.save(output_path)
  140. logger.info(
  141. "[im_approval_ad_creation] xlsx 已生成 records=%d path=%s",
  142. len(records), output_path,
  143. )
  144. return output_path
  145. def _query_first_sheet_id(sheet_token: str) -> str:
  146. """查 sheet_id(对偶 im_approval_creation 同名函数)"""
  147. token = _get_tenant_token()
  148. url = f"{FEISHU_BASE_URL}/sheets/v3/spreadsheets/{sheet_token}/sheets/query"
  149. resp = httpx.get(url, headers=_auth_headers(token), timeout=30)
  150. resp.raise_for_status()
  151. data = resp.json()
  152. if data.get("code") != 0:
  153. raise RuntimeError(f"查 sheet_id 失败: {data}")
  154. sheets = (data.get("data") or {}).get("sheets") or []
  155. if not sheets:
  156. raise RuntimeError(f"sheet_token={sheet_token} 无 sheet")
  157. return sheets[0]["sheet_id"]
  158. async def send_ad_approval_to_feishu(
  159. xlsx_path: Path,
  160. chat_id: str = "",
  161. preamble: str = "",
  162. ) -> dict:
  163. """上传 xlsx → 转飞书 sheet → 发卡片到群。"""
  164. chat_id = chat_id or FEISHU_OPERATOR_CHAT_ID
  165. if not preamble:
  166. preamble = (
  167. "【广告新建审批】下方在线表格列出本次准备建的广告候选。\n"
  168. "请在最右侧【决策】列下拉框选择 approve / reject / hold。\n"
  169. "我们会在 2 小时内或所有行决策完成后,执行 approve 项,跳过 reject / hold。\n"
  170. "差异化机制:无定投 + 有定投(版位定投场景) → 腾讯唯一性绕过。"
  171. )
  172. result = await import_to_feishu(
  173. xlsx_path=str(xlsx_path),
  174. send_im=True,
  175. chat_id=chat_id,
  176. preamble=preamble,
  177. )
  178. meta = result.metadata or {}
  179. sheet_token = meta.get("sheet_token", "")
  180. if not sheet_token:
  181. raise RuntimeError(
  182. f"send_ad_approval_to_feishu: 未拿到 sheet_token result={result}"
  183. )
  184. sheet_id = _query_first_sheet_id(sheet_token)
  185. return {
  186. "url": meta.get("url", ""),
  187. "sheet_token": sheet_token,
  188. "sheet_id": sheet_id,
  189. "im_sent": meta.get("im_sent", False),
  190. "im_message_id": meta.get("im_message_id", ""),
  191. }
  192. def read_actions_from_sheet(
  193. sheet_token: str, sheet_id: str, row_count: int,
  194. ) -> dict[int, str]:
  195. """读决策列,返回 {row_idx: action}。"""
  196. token = _get_tenant_token()
  197. last_row = row_count + 1
  198. range_str = f"{sheet_id}!{DECISION_COL_LETTER}2:{DECISION_COL_LETTER}{last_row}"
  199. url = (
  200. f"{FEISHU_BASE_URL}/sheets/v2/spreadsheets/{sheet_token}"
  201. f"/values/{range_str}?valueRenderOption=ToString"
  202. )
  203. resp = httpx.get(url, headers=_auth_headers(token), timeout=30)
  204. resp.raise_for_status()
  205. data = resp.json()
  206. if data.get("code") != 0:
  207. raise RuntimeError(f"读 sheet 失败 data={data}")
  208. values = (data.get("data") or {}).get("valueRange", {}).get("values", []) or []
  209. actions: dict[int, str] = {}
  210. for i, row in enumerate(values, start=1):
  211. if not row:
  212. continue
  213. cell_raw = row[0]
  214. if cell_raw is None:
  215. continue
  216. cell = str(cell_raw).strip().lower()
  217. if cell in VALID_ACTIONS:
  218. actions[i] = cell
  219. return actions
  220. def poll_ad_approval_actions(
  221. sheet_token: str, sheet_id: str, expected_row_count: int,
  222. timeout_minutes: int = CREATION_APPROVAL_TIMEOUT_MINUTES,
  223. poll_interval_seconds: int = 60,
  224. ) -> dict[int, str]:
  225. """轮询读决策列,完成或超时退出。"""
  226. deadline = time.time() + timeout_minutes * 60
  227. last_count = -1
  228. actions: dict[int, str] = {}
  229. while time.time() < deadline:
  230. actions = read_actions_from_sheet(sheet_token, sheet_id, expected_row_count)
  231. if len(actions) != last_count:
  232. logger.info(
  233. "[poll_ad_approval] %d/%d 已决策",
  234. len(actions), expected_row_count,
  235. )
  236. last_count = len(actions)
  237. if len(actions) >= expected_row_count:
  238. logger.info(
  239. "[poll_ad_approval] 全部完成 %d/%d, 提前退出轮询",
  240. len(actions), expected_row_count,
  241. )
  242. return actions
  243. time.sleep(poll_interval_seconds)
  244. logger.warning(
  245. "[poll_ad_approval] 超时 %d 分钟, 仅 %d/%d 已决策",
  246. timeout_minutes, last_count, expected_row_count,
  247. )
  248. return actions
  249. def run_ad_approval_workflow(
  250. records: list[dict],
  251. xlsx_output_dir: Path,
  252. timeout_minutes: int = CREATION_APPROVAL_TIMEOUT_MINUTES,
  253. ) -> tuple[dict, dict[int, str]]:
  254. """完整模块 A 审批工作流。"""
  255. now = now_in_timezone()
  256. date_str = now.strftime("%Y%m%d")
  257. ts = now.strftime("%H%M%S")
  258. xlsx_path = xlsx_output_dir / f"ad_creation_approval_{date_str}_{ts}.xlsx"
  259. generate_ad_approval_xlsx(records, xlsx_path)
  260. sheet_meta = asyncio.run(send_ad_approval_to_feishu(xlsx_path))
  261. actions = poll_ad_approval_actions(
  262. sheet_token=sheet_meta["sheet_token"],
  263. sheet_id=sheet_meta["sheet_id"],
  264. expected_row_count=len(records),
  265. timeout_minutes=timeout_minutes,
  266. )
  267. return sheet_meta, actions