repository.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888
  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 replace_run_results(
  184. run_id: str,
  185. *,
  186. snapshots: Iterable[dict[str, Any]],
  187. actions: Iterable[dict[str, Any]],
  188. thresholds: dict[str, Any],
  189. ) -> tuple[int, int]:
  190. snapshot_rows = list(snapshots)
  191. action_rows = list(actions)
  192. connection = connect()
  193. connection.autocommit(False)
  194. try:
  195. with connection.cursor() as cursor:
  196. cursor.execute(
  197. "SELECT status FROM roi_metric_run WHERE run_id=%s FOR UPDATE",
  198. (run_id,),
  199. )
  200. run = cursor.fetchone()
  201. if not run:
  202. raise ValueError(f"ROI run not found: {run_id}")
  203. if run["status"] not in {"COMPUTING", "COMPUTED", "FAILED"}:
  204. raise RuntimeError(
  205. f"ROI run {run_id} cannot be replaced in status {run['status']}"
  206. )
  207. cursor.execute("DELETE FROM roi_action_item WHERE run_id=%s", (run_id,))
  208. cursor.execute("DELETE FROM roi_entity_snapshot WHERE run_id=%s", (run_id,))
  209. snapshot_columns = [
  210. "run_id", "entity_hash", "entity_type", "channel",
  211. "account_id", "account_name", "adgroup_id", "adgroup_name",
  212. "dynamic_creative_id", "audience_name", "conversion_goal",
  213. "partner_name", "official_account_name",
  214. "fission_parameter_version", "fission_cohort_date",
  215. "fission_multiplier_vs_t0", "fission_multiplier_vs_first",
  216. "fission_match_level",
  217. "fission_source", "ad_age",
  218. "avg_first_uv", "first_uv",
  219. "t0_fission_count", "t0_fission_rate", "cost",
  220. "efficiency_revenue", "fission_revenue", "actual_total_revenue",
  221. "actual_roi", "predicted_tail_revenue",
  222. "predicted_fission_revenue", "total_revenue", "roi",
  223. "stop_threshold", "scale_threshold", "recommended_action",
  224. "action_reason", "execution_mode", "ineligible_reason",
  225. "daily_metrics_json",
  226. ]
  227. if snapshot_rows:
  228. cursor.executemany(
  229. f"INSERT INTO roi_entity_snapshot ({', '.join(snapshot_columns)}) "
  230. f"VALUES ({', '.join(['%s'] * len(snapshot_columns))})",
  231. [
  232. [
  233. json_dumps(row[column])
  234. if column == "daily_metrics_json"
  235. else row.get(column)
  236. for column in snapshot_columns
  237. ]
  238. for row in snapshot_rows
  239. ],
  240. )
  241. action_columns = [
  242. "run_id", "idempotency_key", "action_type", "account_id",
  243. "adgroup_id", "dynamic_creative_id", "execution_status",
  244. ]
  245. if action_rows:
  246. cursor.executemany(
  247. f"INSERT INTO roi_action_item ({', '.join(action_columns)}) "
  248. f"VALUES ({', '.join(['%s'] * len(action_columns))})",
  249. [
  250. [row.get(column) for column in action_columns]
  251. for row in action_rows
  252. ],
  253. )
  254. cursor.execute(
  255. """
  256. UPDATE roi_metric_run
  257. SET thresholds_json=%s,
  258. status='COMPUTED',
  259. entity_count=%s,
  260. candidate_count=%s,
  261. actionable_count=%s,
  262. error_message=NULL
  263. WHERE run_id=%s
  264. """,
  265. (
  266. json_dumps(thresholds),
  267. len(snapshot_rows),
  268. sum(bool(row.get("recommended_action")) for row in snapshot_rows),
  269. len(action_rows),
  270. run_id,
  271. ),
  272. )
  273. connection.commit()
  274. return len(snapshot_rows), len(action_rows)
  275. except Exception:
  276. connection.rollback()
  277. raise
  278. finally:
  279. connection.close()
  280. def mark_published(
  281. run_id: str,
  282. *,
  283. sheet_token: str,
  284. sheet_url: str,
  285. message_id: str,
  286. expires_at: datetime | None,
  287. requires_approval: bool,
  288. ) -> None:
  289. connection = connect()
  290. try:
  291. with connection.cursor() as cursor:
  292. affected = cursor.execute(
  293. """
  294. UPDATE roi_metric_run
  295. SET status=%s, sheet_token=%s, sheet_url=%s,
  296. message_id=%s, expires_at=%s, error_message=NULL
  297. WHERE run_id=%s AND status IN ('COMPUTED','PENDING_APPROVAL')
  298. """,
  299. (
  300. "PENDING_APPROVAL" if requires_approval else "COMPLETED",
  301. sheet_token,
  302. sheet_url,
  303. message_id,
  304. expires_at,
  305. run_id,
  306. ),
  307. )
  308. if not affected:
  309. raise RuntimeError(f"ROI run cannot be published: {run_id}")
  310. finally:
  311. connection.close()
  312. def mark_failed(run_id: str, error_message: str) -> None:
  313. connection = connect()
  314. try:
  315. with connection.cursor() as cursor:
  316. cursor.execute(
  317. """
  318. UPDATE roi_metric_run
  319. SET status='FAILED', error_message=%s
  320. WHERE run_id=%s AND status NOT IN ('COMPLETED','PARTIAL','REJECTED')
  321. """,
  322. (error_message[:4000], run_id),
  323. )
  324. finally:
  325. connection.close()
  326. def upsert_agency_delivery(record: dict[str, Any]) -> dict[str, Any]:
  327. """Create or refresh one idempotent agency delivery record."""
  328. connection = connect()
  329. try:
  330. with connection.cursor() as cursor:
  331. cursor.execute(
  332. """
  333. INSERT INTO roi_agency_delivery
  334. (run_id, agency_name, agency_report_version, file_path,
  335. file_sha256, creative_rows, ad_rows,
  336. route_fingerprint, status)
  337. VALUES (%s,%s,%s,%s,%s,%s,%s,%s,'PENDING')
  338. ON DUPLICATE KEY UPDATE
  339. status=CASE
  340. WHEN status='SENT' THEN status
  341. WHEN NOT (route_fingerprint <=> VALUES(route_fingerprint))
  342. THEN 'PENDING'
  343. ELSE status
  344. END,
  345. error_message=CASE
  346. WHEN NOT (route_fingerprint <=> VALUES(route_fingerprint))
  347. THEN NULL
  348. ELSE error_message
  349. END,
  350. file_path=IF(status='SENT', file_path, VALUES(file_path)),
  351. file_sha256=IF(status='SENT', file_sha256, VALUES(file_sha256)),
  352. creative_rows=VALUES(creative_rows),
  353. ad_rows=VALUES(ad_rows),
  354. route_fingerprint=VALUES(route_fingerprint)
  355. """,
  356. (
  357. record["run_id"],
  358. record["agency_name"],
  359. record["agency_report_version"],
  360. record["file_path"],
  361. record["file_sha256"],
  362. int(record.get("creative_rows") or 0),
  363. int(record.get("ad_rows") or 0),
  364. record.get("route_fingerprint"),
  365. ),
  366. )
  367. cursor.execute(
  368. """
  369. SELECT * FROM roi_agency_delivery
  370. WHERE run_id=%s AND agency_name=%s AND agency_report_version=%s
  371. """,
  372. (
  373. record["run_id"],
  374. record["agency_name"],
  375. record["agency_report_version"],
  376. ),
  377. )
  378. return cursor.fetchone() or {}
  379. finally:
  380. connection.close()
  381. def load_agency_deliveries(run_id: str) -> list[dict[str, Any]]:
  382. connection = connect()
  383. try:
  384. with connection.cursor() as cursor:
  385. cursor.execute(
  386. """
  387. SELECT * FROM roi_agency_delivery
  388. WHERE run_id=%s
  389. ORDER BY agency_name
  390. """,
  391. (run_id,),
  392. )
  393. return list(cursor.fetchall())
  394. finally:
  395. connection.close()
  396. def update_agency_delivery(
  397. delivery_id: int,
  398. *,
  399. status: str,
  400. sheet_token: str | None = None,
  401. sheet_url: str | None = None,
  402. response_code: str | None = None,
  403. error_message: str | None = None,
  404. sent_at: datetime | None = None,
  405. increment_attempt: bool = False,
  406. ) -> None:
  407. allowed_statuses = {"PENDING", "UPLOADED", "SENT", "SKIPPED", "FAILED"}
  408. if status not in allowed_statuses:
  409. raise ValueError(f"Unsupported agency delivery status: {status}")
  410. connection = connect()
  411. try:
  412. with connection.cursor() as cursor:
  413. cursor.execute(
  414. """
  415. UPDATE roi_agency_delivery
  416. SET status=%s,
  417. sheet_token=COALESCE(%s, sheet_token),
  418. sheet_url=COALESCE(%s, sheet_url),
  419. response_code=%s,
  420. error_message=%s,
  421. sent_at=COALESCE(%s, sent_at),
  422. attempt_count=attempt_count+%s
  423. WHERE id=%s
  424. """,
  425. (
  426. status,
  427. sheet_token,
  428. sheet_url,
  429. response_code,
  430. error_message[:4000] if error_message else None,
  431. sent_at,
  432. 1 if increment_attempt else 0,
  433. delivery_id,
  434. ),
  435. )
  436. finally:
  437. connection.close()
  438. def reject_run(run_id: str, *, sender_open_id: str, now: datetime) -> dict[str, Any]:
  439. connection = connect()
  440. connection.autocommit(False)
  441. try:
  442. with connection.cursor() as cursor:
  443. cursor.execute(
  444. "SELECT * FROM roi_metric_run WHERE run_id=%s FOR UPDATE",
  445. (run_id,),
  446. )
  447. run = cursor.fetchone()
  448. if not run:
  449. raise ValueError(f"ROI批次不存在: {run_id}")
  450. if run["status"] in FINAL_STATUSES:
  451. connection.commit()
  452. return run
  453. if run["status"] != "PENDING_APPROVAL":
  454. raise RuntimeError(f"ROI批次当前不可拒绝: {run['status']}")
  455. if _is_expired(run.get("expires_at"), now):
  456. cursor.execute(
  457. "UPDATE roi_metric_run SET status='EXPIRED' WHERE run_id=%s",
  458. (run_id,),
  459. )
  460. else:
  461. cursor.execute(
  462. """
  463. UPDATE roi_metric_run
  464. SET status='REJECTED', rejected_by=%s, rejected_at=%s
  465. WHERE run_id=%s
  466. """,
  467. (sender_open_id, now, run_id),
  468. )
  469. connection.commit()
  470. return load_run(run_id) or run
  471. except Exception:
  472. connection.rollback()
  473. raise
  474. finally:
  475. connection.close()
  476. def claim_run_for_execution(
  477. run_id: str,
  478. *,
  479. sender_open_id: str,
  480. now: datetime,
  481. ) -> dict[str, Any]:
  482. connection = connect()
  483. connection.autocommit(False)
  484. try:
  485. with connection.cursor() as cursor:
  486. cursor.execute(
  487. "SELECT * FROM roi_metric_run WHERE run_id=%s FOR UPDATE",
  488. (run_id,),
  489. )
  490. run = cursor.fetchone()
  491. if not run:
  492. raise ValueError(f"ROI批次不存在: {run_id}")
  493. if run["status"] in FINAL_STATUSES:
  494. connection.commit()
  495. return run
  496. if run["status"] == "PENDING_APPROVAL":
  497. if _is_expired(run.get("expires_at"), now):
  498. cursor.execute(
  499. "UPDATE roi_metric_run SET status='EXPIRED' WHERE run_id=%s",
  500. (run_id,),
  501. )
  502. connection.commit()
  503. return load_run(run_id) or run
  504. cursor.execute(
  505. """
  506. UPDATE roi_metric_run
  507. SET status='EXECUTING', approved_by=%s, approved_at=%s
  508. WHERE run_id=%s
  509. """,
  510. (sender_open_id, now, run_id),
  511. )
  512. elif run["status"] != "EXECUTING":
  513. raise RuntimeError(f"ROI批次当前不可确认: {run['status']}")
  514. connection.commit()
  515. return load_run(run_id) or run
  516. except Exception:
  517. connection.rollback()
  518. raise
  519. finally:
  520. connection.close()
  521. def load_action_items(run_id: str) -> list[dict[str, Any]]:
  522. connection = connect()
  523. try:
  524. with connection.cursor() as cursor:
  525. cursor.execute(
  526. "SELECT * FROM roi_action_item WHERE run_id=%s ORDER BY id",
  527. (run_id,),
  528. )
  529. return list(cursor.fetchall())
  530. finally:
  531. connection.close()
  532. def load_pending_sheet_runs(now: datetime) -> list[dict[str, Any]]:
  533. connection = connect()
  534. try:
  535. with connection.cursor() as cursor:
  536. cursor.execute(
  537. """
  538. SELECT * FROM roi_metric_run
  539. WHERE status='PENDING_APPROVAL'
  540. AND sheet_token IS NOT NULL
  541. AND (expires_at IS NULL OR expires_at>=%s)
  542. ORDER BY created_at
  543. """,
  544. (now,),
  545. )
  546. return list(cursor.fetchall())
  547. finally:
  548. connection.close()
  549. def expire_pending_sheet_runs(now: datetime) -> int:
  550. connection = connect()
  551. connection.autocommit(False)
  552. try:
  553. with connection.cursor() as cursor:
  554. cursor.execute(
  555. """
  556. SELECT run_id FROM roi_metric_run
  557. WHERE status='PENDING_APPROVAL'
  558. AND expires_at IS NOT NULL
  559. AND expires_at<%s
  560. FOR UPDATE
  561. """,
  562. (now,),
  563. )
  564. run_ids = [row["run_id"] for row in cursor.fetchall()]
  565. if run_ids:
  566. placeholders = ",".join(["%s"] * len(run_ids))
  567. cursor.execute(
  568. f"""
  569. UPDATE roi_action_item
  570. SET approval_status='EXPIRED', execution_status='EXPIRED',
  571. executed_at=%s
  572. WHERE run_id IN ({placeholders})
  573. AND approval_status='PENDING'
  574. """,
  575. [now, *run_ids],
  576. )
  577. cursor.execute(
  578. f"""
  579. UPDATE roi_metric_run SET status='EXPIRED'
  580. WHERE run_id IN ({placeholders})
  581. """,
  582. run_ids,
  583. )
  584. connection.commit()
  585. return len(run_ids)
  586. except Exception:
  587. connection.rollback()
  588. raise
  589. finally:
  590. connection.close()
  591. def record_sheet_decision(
  592. *,
  593. run_id: str,
  594. idempotency_key: str,
  595. decision: str,
  596. sheet_row_number: int,
  597. now: datetime,
  598. ) -> dict[str, Any] | None:
  599. if decision not in {"APPROVED", "REJECTED"}:
  600. raise ValueError(f"Unsupported ROI sheet decision: {decision}")
  601. connection = connect()
  602. connection.autocommit(False)
  603. try:
  604. with connection.cursor() as cursor:
  605. cursor.execute(
  606. "SELECT status, expires_at FROM roi_metric_run "
  607. "WHERE run_id=%s FOR UPDATE",
  608. (run_id,),
  609. )
  610. run = cursor.fetchone()
  611. if not run or run["status"] != "PENDING_APPROVAL":
  612. connection.rollback()
  613. return None
  614. if _is_expired(run.get("expires_at"), now):
  615. connection.rollback()
  616. return None
  617. cursor.execute(
  618. """
  619. SELECT * FROM roi_action_item
  620. WHERE run_id=%s AND idempotency_key=%s
  621. FOR UPDATE
  622. """,
  623. (run_id, idempotency_key),
  624. )
  625. item = cursor.fetchone()
  626. if not item:
  627. connection.rollback()
  628. return None
  629. if item["approval_status"] != "PENDING":
  630. connection.commit()
  631. return {**item, "decision_changed": False}
  632. if decision == "APPROVED":
  633. cursor.execute(
  634. """
  635. UPDATE roi_action_item
  636. SET approval_status='APPROVED', approval_source='FEISHU_SHEET',
  637. approved_at=%s, sheet_row_number=%s
  638. WHERE id=%s AND approval_status='PENDING'
  639. """,
  640. (now, sheet_row_number, item["id"]),
  641. )
  642. else:
  643. cursor.execute(
  644. """
  645. UPDATE roi_action_item
  646. SET approval_status='REJECTED', approval_source='FEISHU_SHEET',
  647. rejected_at=%s, sheet_row_number=%s,
  648. execution_status='REJECTED', executed_at=%s
  649. WHERE id=%s AND approval_status='PENDING'
  650. """,
  651. (now, sheet_row_number, now, item["id"]),
  652. )
  653. connection.commit()
  654. return {
  655. **item,
  656. "approval_status": decision,
  657. "sheet_row_number": sheet_row_number,
  658. "decision_changed": True,
  659. }
  660. except Exception:
  661. connection.rollback()
  662. raise
  663. finally:
  664. connection.close()
  665. def load_actions_by_ids(item_ids: Iterable[int]) -> list[dict[str, Any]]:
  666. ids = [int(value) for value in item_ids]
  667. if not ids:
  668. return []
  669. connection = connect()
  670. try:
  671. with connection.cursor() as cursor:
  672. placeholders = ",".join(["%s"] * len(ids))
  673. cursor.execute(
  674. f"SELECT * FROM roi_action_item WHERE id IN ({placeholders}) ORDER BY id",
  675. ids,
  676. )
  677. return list(cursor.fetchall())
  678. finally:
  679. connection.close()
  680. def load_unnotified_action_details(
  681. run_ids: Iterable[str] | None = None,
  682. ) -> list[dict[str, Any]]:
  683. values = [str(value) for value in run_ids] if run_ids is not None else []
  684. run_filter = ""
  685. params: list[Any] = []
  686. if run_ids is not None:
  687. if not values:
  688. return []
  689. placeholders = ",".join(["%s"] * len(values))
  690. run_filter = f"AND a.run_id IN ({placeholders})"
  691. params.extend(values)
  692. connection = connect()
  693. try:
  694. with connection.cursor() as cursor:
  695. cursor.execute(
  696. f"""
  697. SELECT a.*, r.sheet_url, s.adgroup_name, s.audience_name,
  698. s.cost, s.roi
  699. FROM roi_action_item a
  700. JOIN roi_metric_run r ON r.run_id=a.run_id
  701. LEFT JOIN roi_entity_snapshot s ON s.id=(
  702. SELECT MIN(s2.id) FROM roi_entity_snapshot s2
  703. WHERE s2.run_id=a.run_id
  704. AND s2.account_id=a.account_id
  705. AND s2.adgroup_id=a.adgroup_id
  706. AND (
  707. a.dynamic_creative_id IS NULL
  708. OR s2.dynamic_creative_id=a.dynamic_creative_id
  709. )
  710. )
  711. WHERE a.approval_status IN ('APPROVED','REJECTED')
  712. AND a.execution_status NOT IN ('PENDING','PREPARED')
  713. AND a.result_notified_at IS NULL
  714. {run_filter}
  715. ORDER BY a.run_id, a.id
  716. LIMIT 100
  717. """,
  718. params,
  719. )
  720. return list(cursor.fetchall())
  721. finally:
  722. connection.close()
  723. def mark_action_notifications(
  724. item_ids: Iterable[int],
  725. *,
  726. notified_at: datetime | None,
  727. error: str | None,
  728. ) -> None:
  729. ids = [int(value) for value in item_ids]
  730. if not ids:
  731. return
  732. connection = connect()
  733. try:
  734. with connection.cursor() as cursor:
  735. placeholders = ",".join(["%s"] * len(ids))
  736. cursor.execute(
  737. f"""
  738. UPDATE roi_action_item
  739. SET result_notified_at=%s, notification_error=%s
  740. WHERE id IN ({placeholders})
  741. """,
  742. [notified_at, error[:4000] if error else None, *ids],
  743. )
  744. finally:
  745. connection.close()
  746. def finalize_sheet_run_if_resolved(run_id: str, *, now: datetime) -> dict[str, Any]:
  747. connection = connect()
  748. try:
  749. with connection.cursor() as cursor:
  750. cursor.execute(
  751. """
  752. SELECT
  753. SUM(approval_status='PENDING') AS pending_approvals,
  754. SUM(execution_status='SUCCESS') AS successes,
  755. SUM(execution_status IN ('FAILED','OUTCOME_UNKNOWN','VERIFY_FAILED')) AS failures,
  756. SUM(execution_status LIKE 'SKIPPED%%') AS skipped,
  757. SUM(approval_status='REJECTED') AS rejected
  758. FROM roi_action_item WHERE run_id=%s
  759. """,
  760. (run_id,),
  761. )
  762. counts = cursor.fetchone() or {}
  763. normalized = {key: int(value or 0) for key, value in counts.items()}
  764. if normalized["pending_approvals"]:
  765. return {"status": "PENDING_APPROVAL", **normalized}
  766. status = "PARTIAL" if normalized["failures"] else "COMPLETED"
  767. cursor.execute(
  768. "UPDATE roi_metric_run SET status=%s, executed_at=%s WHERE run_id=%s",
  769. (status, now, run_id),
  770. )
  771. return {"status": status, **normalized}
  772. finally:
  773. connection.close()
  774. def update_action_item(item_id: int, **values: Any) -> None:
  775. allowed = {
  776. "bid_field", "initial_base_bid_fen", "base_bid_fen", "before_bid_fen",
  777. "target_bid_fen", "before_status", "target_status", "readback_status",
  778. "execution_status", "skip_reason", "error_message", "pre_state_json",
  779. "readback_json", "executed_at", "notification_error",
  780. }
  781. unknown = set(values) - allowed
  782. if unknown:
  783. raise ValueError(f"Unsupported ROI action fields: {sorted(unknown)}")
  784. assignments: list[str] = []
  785. params: list[Any] = []
  786. for column, value in values.items():
  787. assignments.append(f"{column}=%s")
  788. params.append(
  789. json_dumps(value)
  790. if column in {"pre_state_json", "readback_json"} and value is not None
  791. else value
  792. )
  793. if not assignments:
  794. return
  795. params.append(item_id)
  796. connection = connect()
  797. try:
  798. with connection.cursor() as cursor:
  799. cursor.execute(
  800. f"UPDATE roi_action_item SET {', '.join(assignments)} WHERE id=%s",
  801. params,
  802. )
  803. finally:
  804. connection.close()
  805. def finalize_run(run_id: str, *, now: datetime) -> dict[str, Any]:
  806. connection = connect()
  807. try:
  808. with connection.cursor() as cursor:
  809. cursor.execute(
  810. """
  811. SELECT
  812. SUM(execution_status='SUCCESS') AS successes,
  813. SUM(execution_status IN ('FAILED','OUTCOME_UNKNOWN','VERIFY_FAILED')) AS failures,
  814. SUM(execution_status LIKE 'SKIPPED%%') AS skipped
  815. FROM roi_action_item WHERE run_id=%s
  816. """,
  817. (run_id,),
  818. )
  819. counts = cursor.fetchone() or {}
  820. failures = int(counts.get("failures") or 0)
  821. status = "PARTIAL" if failures else "COMPLETED"
  822. cursor.execute(
  823. "UPDATE roi_metric_run SET status=%s, executed_at=%s WHERE run_id=%s",
  824. (status, now, run_id),
  825. )
  826. return {"status": status, **{key: int(value or 0) for key, value in counts.items()}}
  827. finally:
  828. connection.close()