sheet_approval.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390
  1. """Poll editable ROI sheets and execute approved database action items."""
  2. from __future__ import annotations
  3. import logging
  4. import os
  5. import threading
  6. from datetime import datetime
  7. from typing import Any
  8. from zoneinfo import ZoneInfo
  9. import httpx
  10. from openpyxl.utils import get_column_letter
  11. from .config import RoiConfig
  12. from .execution import execute_approved_roi_actions
  13. from .feishu import BASE_URL, RoiFeishuPublisher
  14. from .repository import (
  15. expire_pending_sheet_runs,
  16. finalize_sheet_run_if_resolved,
  17. load_actions_by_ids,
  18. load_pending_sheet_runs,
  19. load_unnotified_action_details,
  20. mark_action_notifications,
  21. record_sheet_decision,
  22. )
  23. SHANGHAI = ZoneInfo("Asia/Shanghai")
  24. APPROVAL_SHEET_NAMES = (
  25. "小程序创意级三日汇总",
  26. "小程序广告级三日汇总",
  27. )
  28. LEGACY_APPROVAL_SHEET_NAME = "小程序投流"
  29. APPROVED_VALUES = {"批准", "approve", "approved"}
  30. REJECTED_VALUES = {"拒绝", "reject", "rejected"}
  31. logger = logging.getLogger("auto_put_ad_mini.roi_sheet_approval")
  32. def parse_approval_rows(values: list[list[Any]]) -> list[dict[str, Any]]:
  33. if not values:
  34. return []
  35. headers = {
  36. str(value or "").strip(): index
  37. for index, value in enumerate(values[0])
  38. if str(value or "").strip()
  39. }
  40. required = {"审批选择", "动作幂等键"}
  41. missing = required - headers.keys()
  42. if missing:
  43. raise RuntimeError(f"ROI审批表缺少列: {', '.join(sorted(missing))}")
  44. decisions: list[dict[str, Any]] = []
  45. for row_number, row in enumerate(values[1:], start=2):
  46. approval_index = headers["审批选择"]
  47. key_index = headers["动作幂等键"]
  48. approval = str(row[approval_index] if approval_index < len(row) else "").strip()
  49. key = str(row[key_index] if key_index < len(row) else "").strip()
  50. normalized = approval.lower()
  51. if not key:
  52. continue
  53. if normalized in APPROVED_VALUES:
  54. decision = "APPROVED"
  55. elif normalized in REJECTED_VALUES:
  56. decision = "REJECTED"
  57. else:
  58. continue
  59. decisions.append(
  60. {
  61. "row_number": row_number,
  62. "idempotency_key": key,
  63. "decision": decision,
  64. "headers": headers,
  65. }
  66. )
  67. return decisions
  68. class RoiSheetClient:
  69. def __init__(self, timeout: float = 30.0) -> None:
  70. self.app_id = os.getenv("FEISHU_APP_ID", "").strip()
  71. self.app_secret = os.getenv("FEISHU_APP_SECRET", "").strip()
  72. if not self.app_id or not self.app_secret:
  73. raise RuntimeError("FEISHU_APP_ID and FEISHU_APP_SECRET are required")
  74. self.client = httpx.Client(timeout=timeout)
  75. def close(self) -> None:
  76. self.client.close()
  77. @staticmethod
  78. def _json(response: httpx.Response, action: str) -> dict[str, Any]:
  79. response.raise_for_status()
  80. payload = response.json()
  81. if payload.get("code") != 0:
  82. raise RuntimeError(f"{action} failed: {payload.get('msg', payload)}")
  83. return payload
  84. def _token(self) -> str:
  85. response = self.client.post(
  86. f"{BASE_URL}/auth/v3/tenant_access_token/internal",
  87. json={"app_id": self.app_id, "app_secret": self.app_secret},
  88. )
  89. return self._json(response, "get tenant token")["tenant_access_token"]
  90. @staticmethod
  91. def _headers(token: str) -> dict[str, str]:
  92. return {"Authorization": f"Bearer {token}"}
  93. def _sheet_ids(self, token: str, sheet_token: str) -> list[tuple[str, str]]:
  94. response = self.client.get(
  95. f"{BASE_URL}/sheets/v3/spreadsheets/{sheet_token}/sheets/query",
  96. headers=self._headers(token),
  97. )
  98. sheets = (
  99. self._json(response, "query ROI sheets")
  100. .get("data", {})
  101. .get("sheets", [])
  102. )
  103. by_title = {
  104. str(sheet.get("title") or ""): str(sheet["sheet_id"])
  105. for sheet in sheets
  106. }
  107. targets = [
  108. (title, by_title[title])
  109. for title in APPROVAL_SHEET_NAMES
  110. if title in by_title
  111. ]
  112. if not targets and LEGACY_APPROVAL_SHEET_NAME in by_title:
  113. targets.append(
  114. (LEGACY_APPROVAL_SHEET_NAME, by_title[LEGACY_APPROVAL_SHEET_NAME])
  115. )
  116. if not targets:
  117. expected = "、".join((*APPROVAL_SHEET_NAMES, LEGACY_APPROVAL_SHEET_NAME))
  118. raise RuntimeError(f"ROI审批表缺少工作表: {expected}")
  119. return targets
  120. def _read_values(
  121. self,
  122. token: str,
  123. sheet_token: str,
  124. cell_range: str,
  125. ) -> list[list[Any]]:
  126. response = self.client.get(
  127. f"{BASE_URL}/sheets/v2/spreadsheets/{sheet_token}/values/{cell_range}",
  128. headers=self._headers(token),
  129. params={"valueRenderOption": "ToString"},
  130. )
  131. return (
  132. self._json(response, "read ROI approvals")
  133. .get("data", {})
  134. .get("valueRange", {})
  135. .get("values", [])
  136. ) or []
  137. def read_approvals(self, sheet_token: str) -> list[dict[str, Any]]:
  138. token = self._token()
  139. decisions: list[dict[str, Any]] = []
  140. for sheet_name, sheet_id in self._sheet_ids(token, sheet_token):
  141. decisions.extend(
  142. self._read_sheet_approvals(
  143. token,
  144. sheet_token,
  145. sheet_name=sheet_name,
  146. sheet_id=sheet_id,
  147. )
  148. )
  149. return decisions
  150. def _read_sheet_approvals(
  151. self,
  152. token: str,
  153. sheet_token: str,
  154. *,
  155. sheet_name: str,
  156. sheet_id: str,
  157. ) -> list[dict[str, Any]]:
  158. header_rows = self._read_values(
  159. token,
  160. sheet_token,
  161. f"{sheet_id}!A1:ZZ1",
  162. )
  163. if not header_rows:
  164. return []
  165. headers = {
  166. str(value or "").strip(): index
  167. for index, value in enumerate(header_rows[0])
  168. if str(value or "").strip()
  169. }
  170. required = {"审批选择", "动作幂等键"}
  171. missing = required - headers.keys()
  172. if missing:
  173. raise RuntimeError(f"ROI审批表缺少列: {', '.join(sorted(missing))}")
  174. approval_letter = get_column_letter(headers["审批选择"] + 1)
  175. key_letter = get_column_letter(headers["动作幂等键"] + 1)
  176. approval_rows = self._read_values(
  177. token,
  178. sheet_token,
  179. f"{sheet_id}!{approval_letter}2:{approval_letter}5000",
  180. )
  181. key_rows = self._read_values(
  182. token,
  183. sheet_token,
  184. f"{sheet_id}!{key_letter}2:{key_letter}5000",
  185. )
  186. decisions: list[dict[str, Any]] = []
  187. for offset in range(max(len(approval_rows), len(key_rows))):
  188. approval_row = approval_rows[offset] if offset < len(approval_rows) else []
  189. key_row = key_rows[offset] if offset < len(key_rows) else []
  190. approval = str(approval_row[0] if approval_row else "").strip().lower()
  191. key = str(key_row[0] if key_row else "").strip()
  192. if not key:
  193. continue
  194. if approval in APPROVED_VALUES:
  195. decision = "APPROVED"
  196. elif approval in REJECTED_VALUES:
  197. decision = "REJECTED"
  198. else:
  199. continue
  200. decisions.append(
  201. {
  202. "row_number": offset + 2,
  203. "idempotency_key": key,
  204. "decision": decision,
  205. "headers": headers,
  206. "sheet_id": sheet_id,
  207. "sheet_name": sheet_name,
  208. }
  209. )
  210. return decisions
  211. def write_results(
  212. self,
  213. sheet_token: str,
  214. decisions: list[dict[str, Any]],
  215. actions: list[dict[str, Any]],
  216. ) -> None:
  217. by_id = {int(item["id"]): item for item in actions}
  218. token = self._token()
  219. value_ranges = []
  220. for decision in decisions:
  221. item = by_id.get(int(decision["item_id"]))
  222. if not item:
  223. continue
  224. headers = decision["headers"]
  225. status_column = headers.get("执行状态")
  226. result_column = headers.get("执行结果")
  227. if status_column is None or result_column is None:
  228. continue
  229. execution_status = str(item.get("execution_status") or "")
  230. status = {
  231. "SUCCESS": "执行成功",
  232. "REJECTED": "已拒绝",
  233. "PENDING": "待执行",
  234. "PREPARED": "执行中",
  235. }.get(execution_status, "执行失败" if execution_status else "")
  236. result = str(item.get("error_message") or item.get("skip_reason") or "")
  237. start = get_column_letter(status_column + 1)
  238. end = get_column_letter(result_column + 1)
  239. value_ranges.append(
  240. {
  241. "range": (
  242. f"{decision['sheet_id']}!{start}{decision['row_number']}:"
  243. f"{end}{decision['row_number']}"
  244. ),
  245. "values": [[status, result]],
  246. }
  247. )
  248. if not value_ranges:
  249. return
  250. response = self.client.post(
  251. f"{BASE_URL}/sheets/v2/spreadsheets/"
  252. f"{sheet_token}/values_batch_update",
  253. headers={**self._headers(token), "Content-Type": "application/json"},
  254. json={"valueRanges": value_ranges},
  255. )
  256. self._json(response, "write ROI execution results")
  257. class RoiSheetApprovalService:
  258. def __init__(self) -> None:
  259. self.config = RoiConfig.from_env()
  260. self.lock_name = os.getenv("RTC_DB_LOCK_NAME", "tencent_realtime_control")
  261. self.client = RoiSheetClient()
  262. self.stop_event = threading.Event()
  263. def close(self) -> None:
  264. self.stop_event.set()
  265. self.client.close()
  266. def process_once(self, now: datetime | None = None) -> dict[str, int]:
  267. current = now or datetime.now(SHANGHAI)
  268. expired = expire_pending_sheet_runs(current)
  269. run_count = 0
  270. decision_count = 0
  271. failed_runs = 0
  272. for run in load_pending_sheet_runs(current):
  273. run_count += 1
  274. try:
  275. decision_count += self._process_run(run, current)
  276. except Exception:
  277. failed_runs += 1
  278. logger.exception(
  279. "Skip incompatible or unavailable ROI sheet run=%s",
  280. run["run_id"],
  281. )
  282. self._notify_results(current)
  283. return {
  284. "runs": run_count,
  285. "decisions": decision_count,
  286. "expired": expired,
  287. "failed_runs": failed_runs,
  288. }
  289. def _process_run(self, run: dict[str, Any], now: datetime) -> int:
  290. sheet_decisions = self.client.read_approvals(run["sheet_token"])
  291. approved_ids: list[int] = []
  292. processed_decisions: list[dict[str, Any]] = []
  293. changed_count = 0
  294. for decision in sheet_decisions:
  295. item = record_sheet_decision(
  296. run_id=run["run_id"],
  297. idempotency_key=decision["idempotency_key"],
  298. decision=decision["decision"],
  299. sheet_row_number=decision["row_number"],
  300. now=now,
  301. )
  302. if not item:
  303. continue
  304. changed_count += int(bool(item.get("decision_changed")))
  305. decision["item_id"] = int(item["id"])
  306. processed_decisions.append(decision)
  307. if item.get("approval_status") == "APPROVED":
  308. approved_ids.append(int(item["id"]))
  309. if approved_ids:
  310. execute_approved_roi_actions(
  311. approved_ids,
  312. now=now,
  313. lock_name=self.lock_name,
  314. )
  315. finalize_sheet_run_if_resolved(run["run_id"], now=now)
  316. if processed_decisions:
  317. actions = load_actions_by_ids(
  318. [decision["item_id"] for decision in processed_decisions]
  319. )
  320. try:
  321. self.client.write_results(
  322. run["sheet_token"],
  323. processed_decisions,
  324. actions,
  325. )
  326. except Exception:
  327. logger.exception("Failed to write ROI execution result back to sheet")
  328. return changed_count
  329. def _notify_results(self, now: datetime) -> None:
  330. rows = load_unnotified_action_details()
  331. if not rows:
  332. return
  333. item_ids = [int(row["id"]) for row in rows]
  334. publisher = RoiFeishuPublisher()
  335. try:
  336. publisher.send_execution_results(rows)
  337. except Exception as exc:
  338. mark_action_notifications(item_ids, notified_at=None, error=str(exc))
  339. raise
  340. finally:
  341. publisher.close()
  342. mark_action_notifications(item_ids, notified_at=now, error=None)
  343. def run_forever(self) -> None:
  344. logger.info(
  345. "ROI sheet approval poller started interval=%ss",
  346. self.config.sheet_approval_poll_seconds,
  347. )
  348. while not self.stop_event.is_set():
  349. try:
  350. result = self.process_once()
  351. if result["runs"] or result["expired"]:
  352. logger.info("ROI sheet approval cycle result=%s", result)
  353. except Exception:
  354. logger.exception("ROI sheet approval cycle failed")
  355. self.stop_event.wait(self.config.sheet_approval_poll_seconds)
  356. def start(self) -> threading.Thread:
  357. thread = threading.Thread(
  358. target=self.run_forever,
  359. name="roi-sheet-approval",
  360. daemon=True,
  361. )
  362. thread.start()
  363. return thread