repository.py 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917
  1. """MySQL persistence for versioned ROI runs, snapshots, and action audit."""
  2. from __future__ import annotations
  3. import json
  4. from datetime import date, datetime
  5. from decimal import Decimal
  6. from typing import Any, Iterable
  7. from storage import connect
  8. FINAL_STATUSES = {
  9. "COMPLETED",
  10. "PARTIAL",
  11. "REJECTED",
  12. "EXPIRED",
  13. }
  14. def _is_expired(expires_at: datetime | None, now: datetime) -> bool:
  15. if expires_at is None:
  16. return False
  17. if expires_at.tzinfo is None and now.tzinfo is not None:
  18. now = now.replace(tzinfo=None)
  19. elif expires_at.tzinfo is not None and now.tzinfo is None:
  20. expires_at = expires_at.replace(tzinfo=None)
  21. return now > expires_at
  22. def json_dumps(value: Any) -> str:
  23. def default(item: Any) -> Any:
  24. if isinstance(item, (datetime, date)):
  25. return item.isoformat()
  26. if isinstance(item, Decimal):
  27. return str(item)
  28. if hasattr(item, "item"):
  29. return item.item()
  30. if hasattr(item, "tolist"):
  31. return item.tolist()
  32. raise TypeError(f"Unsupported JSON value: {type(item)!r}")
  33. return json.dumps(value, ensure_ascii=False, default=default)
  34. def publish_fission_parameter_release(
  35. release: dict[str, Any],
  36. values: Iterable[dict[str, Any]],
  37. *,
  38. published_by: str,
  39. ) -> bool:
  40. """Publish an immutable parameter version; return False if already present."""
  41. value_rows = list(values)
  42. connection = connect()
  43. connection.autocommit(False)
  44. try:
  45. with connection.cursor() as cursor:
  46. cursor.execute(
  47. "SELECT content_sha256 FROM roi_fission_parameter_release "
  48. "WHERE version=%s FOR UPDATE",
  49. (release["version"],),
  50. )
  51. existing = cursor.fetchone()
  52. if existing:
  53. if existing["content_sha256"] != release["content_sha256"]:
  54. raise ValueError(
  55. "传播裂变系数版本已存在且内容不同,禁止覆盖: "
  56. f"{release['version']}"
  57. )
  58. connection.rollback()
  59. return False
  60. cursor.execute(
  61. """
  62. INSERT INTO roi_fission_parameter_release
  63. (version, cohort_date, observation_end_date, horizon_days,
  64. run_suffix, content_sha256, metadata_json, published_by)
  65. VALUES (%s,%s,%s,%s,%s,%s,%s,%s)
  66. """,
  67. (
  68. release["version"],
  69. release["cohort_date"],
  70. release["observation_end_date"],
  71. release["horizon_days"],
  72. release["run_suffix"],
  73. release["content_sha256"],
  74. json_dumps(release["metadata"]),
  75. published_by,
  76. ),
  77. )
  78. cursor.executemany(
  79. """
  80. INSERT INTO roi_fission_parameter_value
  81. (version, entity_type, match_level, key_primary,
  82. key_secondary, multiplier, multiplier_vs_first)
  83. VALUES (%s,%s,%s,%s,%s,%s,%s)
  84. """,
  85. [
  86. (
  87. release["version"],
  88. row["entity_type"],
  89. row["match_level"],
  90. row["key_primary"],
  91. row["key_secondary"],
  92. row["multiplier"],
  93. row.get("multiplier_vs_first"),
  94. )
  95. for row in value_rows
  96. ],
  97. )
  98. connection.commit()
  99. return True
  100. except Exception:
  101. connection.rollback()
  102. raise
  103. finally:
  104. connection.close()
  105. def load_fission_parameter_release(
  106. version: str,
  107. ) -> tuple[dict[str, Any], list[dict[str, Any]]]:
  108. connection = connect()
  109. try:
  110. with connection.cursor() as cursor:
  111. cursor.execute(
  112. "SELECT * FROM roi_fission_parameter_release WHERE version=%s",
  113. (version,),
  114. )
  115. release = cursor.fetchone()
  116. if not release:
  117. raise ValueError(f"数据库不存在传播裂变系数版本: {version}")
  118. cursor.execute(
  119. """
  120. SELECT entity_type, match_level, key_primary,
  121. key_secondary, multiplier, multiplier_vs_first
  122. FROM roi_fission_parameter_value
  123. WHERE version=%s
  124. ORDER BY entity_type, match_level, key_primary, key_secondary
  125. """,
  126. (version,),
  127. )
  128. return release, list(cursor.fetchall())
  129. finally:
  130. connection.close()
  131. def create_or_load_run(record: dict[str, Any]) -> dict[str, Any]:
  132. connection = connect()
  133. try:
  134. with connection.cursor() as cursor:
  135. cursor.execute(
  136. """
  137. INSERT IGNORE INTO roi_metric_run
  138. (run_id, run_key, metric_version, policy_version,
  139. fission_parameter_version, fission_cohort_date,
  140. start_date, end_date, config_json, status)
  141. VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,'COMPUTING')
  142. """,
  143. (
  144. record["run_id"],
  145. record["run_key"],
  146. record["metric_version"],
  147. record["policy_version"],
  148. record["fission_parameter_version"],
  149. record["fission_cohort_date"],
  150. record["start_date"],
  151. record["end_date"],
  152. json_dumps(record["config"]),
  153. ),
  154. )
  155. cursor.execute(
  156. "SELECT * FROM roi_metric_run WHERE run_key=%s",
  157. (record["run_key"],),
  158. )
  159. run = cursor.fetchone()
  160. if run:
  161. return run
  162. cursor.execute(
  163. "SELECT * FROM roi_metric_run WHERE run_id=%s",
  164. (record["run_id"],),
  165. )
  166. collision = cursor.fetchone()
  167. if collision:
  168. raise RuntimeError(
  169. f"ROI run_id already exists with a different run_key: "
  170. f"{record['run_id']}"
  171. )
  172. return {}
  173. finally:
  174. connection.close()
  175. def load_run(run_id: str) -> dict[str, Any] | None:
  176. connection = connect()
  177. try:
  178. with connection.cursor() as cursor:
  179. cursor.execute("SELECT * FROM roi_metric_run WHERE run_id=%s", (run_id,))
  180. return cursor.fetchone()
  181. finally:
  182. connection.close()
  183. def load_formal_run_for_end_date(end_date: date) -> dict[str, Any] | None:
  184. """Return an existing formal run for a data date, excluding internal tests."""
  185. connection = connect()
  186. try:
  187. with connection.cursor() as cursor:
  188. cursor.execute(
  189. """
  190. SELECT * FROM roi_metric_run
  191. WHERE end_date=%s
  192. ORDER BY created_at DESC
  193. """,
  194. (end_date,),
  195. )
  196. for run in cursor.fetchall():
  197. try:
  198. config = json.loads(run.get("config_json") or "{}")
  199. except (TypeError, json.JSONDecodeError):
  200. config = {}
  201. internal_test = config.get("internal_test")
  202. if internal_test is None:
  203. internal_test = "_internal_test" in str(run.get("run_id") or "")
  204. if not internal_test:
  205. return run
  206. return None
  207. finally:
  208. connection.close()
  209. def replace_run_results(
  210. run_id: str,
  211. *,
  212. snapshots: Iterable[dict[str, Any]],
  213. actions: Iterable[dict[str, Any]],
  214. thresholds: dict[str, Any],
  215. ) -> tuple[int, int]:
  216. snapshot_rows = list(snapshots)
  217. action_rows = list(actions)
  218. connection = connect()
  219. connection.autocommit(False)
  220. try:
  221. with connection.cursor() as cursor:
  222. cursor.execute(
  223. "SELECT status FROM roi_metric_run WHERE run_id=%s FOR UPDATE",
  224. (run_id,),
  225. )
  226. run = cursor.fetchone()
  227. if not run:
  228. raise ValueError(f"ROI run not found: {run_id}")
  229. if run["status"] not in {"COMPUTING", "COMPUTED", "FAILED"}:
  230. raise RuntimeError(
  231. f"ROI run {run_id} cannot be replaced in status {run['status']}"
  232. )
  233. cursor.execute("DELETE FROM roi_action_item WHERE run_id=%s", (run_id,))
  234. cursor.execute("DELETE FROM roi_entity_snapshot WHERE run_id=%s", (run_id,))
  235. snapshot_columns = [
  236. "run_id", "entity_hash", "entity_type", "channel",
  237. "account_id", "account_name", "adgroup_id", "adgroup_name",
  238. "dynamic_creative_id", "audience_name", "conversion_goal",
  239. "partner_name", "official_account_name",
  240. "fission_parameter_version", "fission_cohort_date",
  241. "fission_multiplier_vs_t0", "fission_multiplier_vs_first",
  242. "fission_match_level",
  243. "fission_source", "ad_age",
  244. "avg_first_uv", "first_uv",
  245. "t0_fission_count", "t0_fission_rate", "cost",
  246. "efficiency_revenue", "fission_revenue", "actual_total_revenue",
  247. "actual_roi", "predicted_tail_revenue",
  248. "predicted_fission_revenue", "total_revenue", "roi",
  249. "stop_threshold", "scale_threshold", "recommended_action",
  250. "action_reason", "execution_mode", "ineligible_reason",
  251. "daily_metrics_json",
  252. ]
  253. if snapshot_rows:
  254. cursor.executemany(
  255. f"INSERT INTO roi_entity_snapshot ({', '.join(snapshot_columns)}) "
  256. f"VALUES ({', '.join(['%s'] * len(snapshot_columns))})",
  257. [
  258. [
  259. json_dumps(row[column])
  260. if column == "daily_metrics_json"
  261. else row.get(column)
  262. for column in snapshot_columns
  263. ]
  264. for row in snapshot_rows
  265. ],
  266. )
  267. action_columns = [
  268. "run_id", "idempotency_key", "action_type", "account_id",
  269. "adgroup_id", "dynamic_creative_id", "execution_status",
  270. ]
  271. if action_rows:
  272. cursor.executemany(
  273. f"INSERT INTO roi_action_item ({', '.join(action_columns)}) "
  274. f"VALUES ({', '.join(['%s'] * len(action_columns))})",
  275. [
  276. [row.get(column) for column in action_columns]
  277. for row in action_rows
  278. ],
  279. )
  280. cursor.execute(
  281. """
  282. UPDATE roi_metric_run
  283. SET thresholds_json=%s,
  284. status='COMPUTED',
  285. entity_count=%s,
  286. candidate_count=%s,
  287. actionable_count=%s,
  288. error_message=NULL
  289. WHERE run_id=%s
  290. """,
  291. (
  292. json_dumps(thresholds),
  293. len(snapshot_rows),
  294. sum(bool(row.get("recommended_action")) for row in snapshot_rows),
  295. len(action_rows),
  296. run_id,
  297. ),
  298. )
  299. connection.commit()
  300. return len(snapshot_rows), len(action_rows)
  301. except Exception:
  302. connection.rollback()
  303. raise
  304. finally:
  305. connection.close()
  306. def mark_published(
  307. run_id: str,
  308. *,
  309. sheet_token: str,
  310. sheet_url: str,
  311. message_id: str,
  312. expires_at: datetime | None,
  313. requires_approval: bool,
  314. ) -> None:
  315. connection = connect()
  316. try:
  317. with connection.cursor() as cursor:
  318. affected = cursor.execute(
  319. """
  320. UPDATE roi_metric_run
  321. SET status=%s, sheet_token=%s, sheet_url=%s,
  322. message_id=%s, expires_at=%s, error_message=NULL
  323. WHERE run_id=%s AND status IN ('COMPUTED','PENDING_APPROVAL')
  324. """,
  325. (
  326. "PENDING_APPROVAL" if requires_approval else "COMPLETED",
  327. sheet_token,
  328. sheet_url,
  329. message_id,
  330. expires_at,
  331. run_id,
  332. ),
  333. )
  334. if not affected:
  335. raise RuntimeError(f"ROI run cannot be published: {run_id}")
  336. finally:
  337. connection.close()
  338. def mark_failed(run_id: str, error_message: str) -> None:
  339. connection = connect()
  340. try:
  341. with connection.cursor() as cursor:
  342. cursor.execute(
  343. """
  344. UPDATE roi_metric_run
  345. SET status='FAILED', error_message=%s
  346. WHERE run_id=%s AND status NOT IN ('COMPLETED','PARTIAL','REJECTED')
  347. """,
  348. (error_message[:4000], run_id),
  349. )
  350. finally:
  351. connection.close()
  352. def upsert_agency_delivery(record: dict[str, Any]) -> dict[str, Any]:
  353. """Create or refresh one idempotent agency delivery record."""
  354. connection = connect()
  355. try:
  356. with connection.cursor() as cursor:
  357. cursor.execute(
  358. """
  359. INSERT INTO roi_agency_delivery
  360. (run_id, agency_name, agency_report_version, file_path,
  361. file_sha256, creative_rows, ad_rows,
  362. route_fingerprint, status)
  363. VALUES (%s,%s,%s,%s,%s,%s,%s,%s,'PENDING')
  364. ON DUPLICATE KEY UPDATE
  365. status=CASE
  366. WHEN status='SENT' THEN status
  367. WHEN NOT (route_fingerprint <=> VALUES(route_fingerprint))
  368. THEN 'PENDING'
  369. ELSE status
  370. END,
  371. error_message=CASE
  372. WHEN NOT (route_fingerprint <=> VALUES(route_fingerprint))
  373. THEN NULL
  374. ELSE error_message
  375. END,
  376. file_path=IF(status='SENT', file_path, VALUES(file_path)),
  377. file_sha256=IF(status='SENT', file_sha256, VALUES(file_sha256)),
  378. creative_rows=VALUES(creative_rows),
  379. ad_rows=VALUES(ad_rows),
  380. route_fingerprint=VALUES(route_fingerprint)
  381. """,
  382. (
  383. record["run_id"],
  384. record["agency_name"],
  385. record["agency_report_version"],
  386. record["file_path"],
  387. record["file_sha256"],
  388. int(record.get("creative_rows") or 0),
  389. int(record.get("ad_rows") or 0),
  390. record.get("route_fingerprint"),
  391. ),
  392. )
  393. cursor.execute(
  394. """
  395. SELECT * FROM roi_agency_delivery
  396. WHERE run_id=%s AND agency_name=%s AND agency_report_version=%s
  397. """,
  398. (
  399. record["run_id"],
  400. record["agency_name"],
  401. record["agency_report_version"],
  402. ),
  403. )
  404. return cursor.fetchone() or {}
  405. finally:
  406. connection.close()
  407. def load_agency_deliveries(run_id: str) -> list[dict[str, Any]]:
  408. connection = connect()
  409. try:
  410. with connection.cursor() as cursor:
  411. cursor.execute(
  412. """
  413. SELECT * FROM roi_agency_delivery
  414. WHERE run_id=%s
  415. ORDER BY agency_name
  416. """,
  417. (run_id,),
  418. )
  419. return list(cursor.fetchall())
  420. finally:
  421. connection.close()
  422. def update_agency_delivery(
  423. delivery_id: int,
  424. *,
  425. status: str,
  426. sheet_token: str | None = None,
  427. sheet_url: str | None = None,
  428. response_code: str | None = None,
  429. error_message: str | None = None,
  430. sent_at: datetime | None = None,
  431. increment_attempt: bool = False,
  432. ) -> None:
  433. allowed_statuses = {"PENDING", "UPLOADED", "SENT", "SKIPPED", "FAILED"}
  434. if status not in allowed_statuses:
  435. raise ValueError(f"Unsupported agency delivery status: {status}")
  436. connection = connect()
  437. try:
  438. with connection.cursor() as cursor:
  439. cursor.execute(
  440. """
  441. UPDATE roi_agency_delivery
  442. SET status=%s,
  443. sheet_token=COALESCE(%s, sheet_token),
  444. sheet_url=COALESCE(%s, sheet_url),
  445. response_code=%s,
  446. error_message=%s,
  447. sent_at=COALESCE(%s, sent_at),
  448. attempt_count=attempt_count+%s
  449. WHERE id=%s
  450. """,
  451. (
  452. status,
  453. sheet_token,
  454. sheet_url,
  455. response_code,
  456. error_message[:4000] if error_message else None,
  457. sent_at,
  458. 1 if increment_attempt else 0,
  459. delivery_id,
  460. ),
  461. )
  462. finally:
  463. connection.close()
  464. def reject_run(run_id: str, *, sender_open_id: str, now: datetime) -> dict[str, Any]:
  465. connection = connect()
  466. connection.autocommit(False)
  467. try:
  468. with connection.cursor() as cursor:
  469. cursor.execute(
  470. "SELECT * FROM roi_metric_run WHERE run_id=%s FOR UPDATE",
  471. (run_id,),
  472. )
  473. run = cursor.fetchone()
  474. if not run:
  475. raise ValueError(f"ROI批次不存在: {run_id}")
  476. if run["status"] in FINAL_STATUSES:
  477. connection.commit()
  478. return run
  479. if run["status"] != "PENDING_APPROVAL":
  480. raise RuntimeError(f"ROI批次当前不可拒绝: {run['status']}")
  481. if _is_expired(run.get("expires_at"), now):
  482. cursor.execute(
  483. "UPDATE roi_metric_run SET status='EXPIRED' WHERE run_id=%s",
  484. (run_id,),
  485. )
  486. else:
  487. cursor.execute(
  488. """
  489. UPDATE roi_metric_run
  490. SET status='REJECTED', rejected_by=%s, rejected_at=%s
  491. WHERE run_id=%s
  492. """,
  493. (sender_open_id, now, run_id),
  494. )
  495. connection.commit()
  496. return load_run(run_id) or run
  497. except Exception:
  498. connection.rollback()
  499. raise
  500. finally:
  501. connection.close()
  502. def claim_run_for_execution(
  503. run_id: str,
  504. *,
  505. sender_open_id: str,
  506. now: datetime,
  507. ) -> dict[str, Any]:
  508. connection = connect()
  509. connection.autocommit(False)
  510. try:
  511. with connection.cursor() as cursor:
  512. cursor.execute(
  513. "SELECT * FROM roi_metric_run WHERE run_id=%s FOR UPDATE",
  514. (run_id,),
  515. )
  516. run = cursor.fetchone()
  517. if not run:
  518. raise ValueError(f"ROI批次不存在: {run_id}")
  519. if run["status"] in FINAL_STATUSES:
  520. connection.commit()
  521. return run
  522. if run["status"] == "PENDING_APPROVAL":
  523. if _is_expired(run.get("expires_at"), now):
  524. cursor.execute(
  525. "UPDATE roi_metric_run SET status='EXPIRED' WHERE run_id=%s",
  526. (run_id,),
  527. )
  528. connection.commit()
  529. return load_run(run_id) or run
  530. cursor.execute(
  531. """
  532. UPDATE roi_metric_run
  533. SET status='EXECUTING', approved_by=%s, approved_at=%s
  534. WHERE run_id=%s
  535. """,
  536. (sender_open_id, now, run_id),
  537. )
  538. elif run["status"] != "EXECUTING":
  539. raise RuntimeError(f"ROI批次当前不可确认: {run['status']}")
  540. connection.commit()
  541. return load_run(run_id) or run
  542. except Exception:
  543. connection.rollback()
  544. raise
  545. finally:
  546. connection.close()
  547. def load_action_items(run_id: str) -> list[dict[str, Any]]:
  548. connection = connect()
  549. try:
  550. with connection.cursor() as cursor:
  551. cursor.execute(
  552. "SELECT * FROM roi_action_item WHERE run_id=%s ORDER BY id",
  553. (run_id,),
  554. )
  555. return list(cursor.fetchall())
  556. finally:
  557. connection.close()
  558. def load_pending_sheet_runs(now: datetime) -> list[dict[str, Any]]:
  559. connection = connect()
  560. try:
  561. with connection.cursor() as cursor:
  562. cursor.execute(
  563. """
  564. SELECT * FROM roi_metric_run
  565. WHERE status='PENDING_APPROVAL'
  566. AND sheet_token IS NOT NULL
  567. AND (expires_at IS NULL OR expires_at>=%s)
  568. ORDER BY created_at
  569. """,
  570. (now,),
  571. )
  572. return list(cursor.fetchall())
  573. finally:
  574. connection.close()
  575. def expire_pending_sheet_runs(now: datetime) -> int:
  576. connection = connect()
  577. connection.autocommit(False)
  578. try:
  579. with connection.cursor() as cursor:
  580. cursor.execute(
  581. """
  582. SELECT run_id FROM roi_metric_run
  583. WHERE status='PENDING_APPROVAL'
  584. AND expires_at IS NOT NULL
  585. AND expires_at<%s
  586. FOR UPDATE
  587. """,
  588. (now,),
  589. )
  590. run_ids = [row["run_id"] for row in cursor.fetchall()]
  591. if run_ids:
  592. placeholders = ",".join(["%s"] * len(run_ids))
  593. cursor.execute(
  594. f"""
  595. UPDATE roi_action_item
  596. SET approval_status='EXPIRED', execution_status='EXPIRED',
  597. executed_at=%s
  598. WHERE run_id IN ({placeholders})
  599. AND approval_status='PENDING'
  600. """,
  601. [now, *run_ids],
  602. )
  603. cursor.execute(
  604. f"""
  605. UPDATE roi_metric_run SET status='EXPIRED'
  606. WHERE run_id IN ({placeholders})
  607. """,
  608. run_ids,
  609. )
  610. connection.commit()
  611. return len(run_ids)
  612. except Exception:
  613. connection.rollback()
  614. raise
  615. finally:
  616. connection.close()
  617. def record_sheet_decision(
  618. *,
  619. run_id: str,
  620. idempotency_key: str,
  621. decision: str,
  622. sheet_row_number: int,
  623. now: datetime,
  624. ) -> dict[str, Any] | None:
  625. if decision not in {"APPROVED", "REJECTED"}:
  626. raise ValueError(f"Unsupported ROI sheet decision: {decision}")
  627. connection = connect()
  628. connection.autocommit(False)
  629. try:
  630. with connection.cursor() as cursor:
  631. cursor.execute(
  632. "SELECT status, expires_at FROM roi_metric_run "
  633. "WHERE run_id=%s FOR UPDATE",
  634. (run_id,),
  635. )
  636. run = cursor.fetchone()
  637. if not run or run["status"] != "PENDING_APPROVAL":
  638. connection.rollback()
  639. return None
  640. if _is_expired(run.get("expires_at"), now):
  641. connection.rollback()
  642. return None
  643. cursor.execute(
  644. """
  645. SELECT * FROM roi_action_item
  646. WHERE run_id=%s AND idempotency_key=%s
  647. FOR UPDATE
  648. """,
  649. (run_id, idempotency_key),
  650. )
  651. item = cursor.fetchone()
  652. if not item:
  653. connection.rollback()
  654. return None
  655. if item["approval_status"] != "PENDING":
  656. connection.commit()
  657. return {**item, "decision_changed": False}
  658. if decision == "APPROVED":
  659. cursor.execute(
  660. """
  661. UPDATE roi_action_item
  662. SET approval_status='APPROVED', approval_source='FEISHU_SHEET',
  663. approved_at=%s, sheet_row_number=%s
  664. WHERE id=%s AND approval_status='PENDING'
  665. """,
  666. (now, sheet_row_number, item["id"]),
  667. )
  668. else:
  669. cursor.execute(
  670. """
  671. UPDATE roi_action_item
  672. SET approval_status='REJECTED', approval_source='FEISHU_SHEET',
  673. rejected_at=%s, sheet_row_number=%s,
  674. execution_status='REJECTED', executed_at=%s
  675. WHERE id=%s AND approval_status='PENDING'
  676. """,
  677. (now, sheet_row_number, now, item["id"]),
  678. )
  679. connection.commit()
  680. return {
  681. **item,
  682. "approval_status": decision,
  683. "sheet_row_number": sheet_row_number,
  684. "decision_changed": True,
  685. }
  686. except Exception:
  687. connection.rollback()
  688. raise
  689. finally:
  690. connection.close()
  691. def load_actions_by_ids(item_ids: Iterable[int]) -> list[dict[str, Any]]:
  692. ids = [int(value) for value in item_ids]
  693. if not ids:
  694. return []
  695. connection = connect()
  696. try:
  697. with connection.cursor() as cursor:
  698. placeholders = ",".join(["%s"] * len(ids))
  699. cursor.execute(
  700. f"SELECT * FROM roi_action_item WHERE id IN ({placeholders}) ORDER BY id",
  701. ids,
  702. )
  703. return list(cursor.fetchall())
  704. finally:
  705. connection.close()
  706. def load_unnotified_action_details(
  707. run_ids: Iterable[str] | None = None,
  708. ) -> list[dict[str, Any]]:
  709. values = [str(value) for value in run_ids] if run_ids is not None else []
  710. run_filter = ""
  711. params: list[Any] = []
  712. if run_ids is not None:
  713. if not values:
  714. return []
  715. placeholders = ",".join(["%s"] * len(values))
  716. run_filter = f"AND a.run_id IN ({placeholders})"
  717. params.extend(values)
  718. connection = connect()
  719. try:
  720. with connection.cursor() as cursor:
  721. cursor.execute(
  722. f"""
  723. SELECT a.*, r.sheet_url, s.adgroup_name, s.audience_name,
  724. s.cost, s.roi
  725. FROM roi_action_item a
  726. JOIN roi_metric_run r ON r.run_id=a.run_id
  727. LEFT JOIN roi_entity_snapshot s ON s.id=(
  728. SELECT MIN(s2.id) FROM roi_entity_snapshot s2
  729. WHERE s2.run_id=a.run_id
  730. AND s2.account_id=a.account_id
  731. AND s2.adgroup_id=a.adgroup_id
  732. AND (
  733. a.dynamic_creative_id IS NULL
  734. OR s2.dynamic_creative_id=a.dynamic_creative_id
  735. )
  736. )
  737. WHERE a.approval_status IN ('APPROVED','REJECTED')
  738. AND a.execution_status NOT IN ('PENDING','PREPARED')
  739. AND a.result_notified_at IS NULL
  740. {run_filter}
  741. ORDER BY a.run_id, a.id
  742. LIMIT 100
  743. """,
  744. params,
  745. )
  746. return list(cursor.fetchall())
  747. finally:
  748. connection.close()
  749. def mark_action_notifications(
  750. item_ids: Iterable[int],
  751. *,
  752. notified_at: datetime | None,
  753. error: str | None,
  754. ) -> None:
  755. ids = [int(value) for value in item_ids]
  756. if not ids:
  757. return
  758. connection = connect()
  759. try:
  760. with connection.cursor() as cursor:
  761. placeholders = ",".join(["%s"] * len(ids))
  762. cursor.execute(
  763. f"""
  764. UPDATE roi_action_item
  765. SET result_notified_at=%s, notification_error=%s
  766. WHERE id IN ({placeholders})
  767. """,
  768. [notified_at, error[:4000] if error else None, *ids],
  769. )
  770. finally:
  771. connection.close()
  772. def finalize_sheet_run_if_resolved(run_id: str, *, now: datetime) -> dict[str, Any]:
  773. connection = connect()
  774. try:
  775. with connection.cursor() as cursor:
  776. cursor.execute(
  777. """
  778. SELECT
  779. SUM(approval_status='PENDING') AS pending_approvals,
  780. SUM(execution_status='SUCCESS') AS successes,
  781. SUM(execution_status IN ('FAILED','OUTCOME_UNKNOWN','VERIFY_FAILED')) AS failures,
  782. SUM(execution_status LIKE 'SKIPPED%%') AS skipped,
  783. SUM(approval_status='REJECTED') AS rejected
  784. FROM roi_action_item WHERE run_id=%s
  785. """,
  786. (run_id,),
  787. )
  788. counts = cursor.fetchone() or {}
  789. normalized = {key: int(value or 0) for key, value in counts.items()}
  790. if normalized["pending_approvals"]:
  791. return {"status": "PENDING_APPROVAL", **normalized}
  792. status = "PARTIAL" if normalized["failures"] else "COMPLETED"
  793. cursor.execute(
  794. "UPDATE roi_metric_run SET status=%s, executed_at=%s WHERE run_id=%s",
  795. (status, now, run_id),
  796. )
  797. return {"status": status, **normalized}
  798. finally:
  799. connection.close()
  800. def update_action_item(item_id: int, **values: Any) -> None:
  801. allowed = {
  802. "bid_field", "initial_base_bid_fen", "base_bid_fen", "before_bid_fen",
  803. "target_bid_fen", "before_status", "target_status", "readback_status",
  804. "execution_status", "skip_reason", "error_message", "pre_state_json",
  805. "readback_json", "executed_at", "notification_error",
  806. }
  807. unknown = set(values) - allowed
  808. if unknown:
  809. raise ValueError(f"Unsupported ROI action fields: {sorted(unknown)}")
  810. assignments: list[str] = []
  811. params: list[Any] = []
  812. for column, value in values.items():
  813. assignments.append(f"{column}=%s")
  814. params.append(
  815. json_dumps(value)
  816. if column in {"pre_state_json", "readback_json"} and value is not None
  817. else value
  818. )
  819. if not assignments:
  820. return
  821. params.append(item_id)
  822. connection = connect()
  823. try:
  824. with connection.cursor() as cursor:
  825. cursor.execute(
  826. f"UPDATE roi_action_item SET {', '.join(assignments)} WHERE id=%s",
  827. params,
  828. )
  829. finally:
  830. connection.close()
  831. def finalize_run(run_id: str, *, now: datetime) -> dict[str, Any]:
  832. connection = connect()
  833. try:
  834. with connection.cursor() as cursor:
  835. cursor.execute(
  836. """
  837. SELECT
  838. SUM(execution_status='SUCCESS') AS successes,
  839. SUM(execution_status IN ('FAILED','OUTCOME_UNKNOWN','VERIFY_FAILED')) AS failures,
  840. SUM(execution_status LIKE 'SKIPPED%%') AS skipped
  841. FROM roi_action_item WHERE run_id=%s
  842. """,
  843. (run_id,),
  844. )
  845. counts = cursor.fetchone() or {}
  846. failures = int(counts.get("failures") or 0)
  847. status = "PARTIAL" if failures else "COMPLETED"
  848. cursor.execute(
  849. "UPDATE roi_metric_run SET status=%s, executed_at=%s WHERE run_id=%s",
  850. (status, now, run_id),
  851. )
  852. return {"status": status, **{key: int(value or 0) for key, value in counts.items()}}
  853. finally:
  854. connection.close()