test_creative_performance_cleanup.py 79 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182
  1. import json
  2. import unittest
  3. import os
  4. from contextlib import contextmanager
  5. from datetime import date, datetime
  6. from pathlib import Path
  7. from tempfile import TemporaryDirectory
  8. from unittest.mock import Mock, patch
  9. from zoneinfo import ZoneInfo
  10. class CreativePerformanceRuleTests(unittest.TestCase):
  11. def setUp(self):
  12. from tools.creative_rejection_cleanup import performance_cleanup_config
  13. self.config = {
  14. "new_min_age_days": 5,
  15. "new_max_age_days": 7,
  16. "new_impressions_threshold": 100,
  17. "old_daily_impressions_threshold": 100.0,
  18. "window_days": 7,
  19. }
  20. # Keep an import-time reference so failures in the public config helper
  21. # are caught by this focused test module too.
  22. self.assertTrue(callable(performance_cleanup_config))
  23. @staticmethod
  24. def _creative(**overrides):
  25. creative = {
  26. "configured_status": "AD_STATUS_NORMAL",
  27. "creative_set_approval_status": (
  28. "CREATIVE_SET_APPROVAL_STATUS_NORMAL"
  29. ),
  30. }
  31. creative.update(overrides)
  32. return creative
  33. @staticmethod
  34. def _ad(**overrides):
  35. ad = {"configured_status": "AD_STATUS_NORMAL"}
  36. ad.update(overrides)
  37. return ad
  38. def _action(self, *, age_days, impressions, cost_fen=0, **overrides):
  39. from tools.creative_rejection_cleanup import (
  40. determine_performance_cleanup_action,
  41. )
  42. as_of = date(2026, 8, 19)
  43. created = datetime(
  44. 2026,
  45. 8,
  46. 19 - age_days,
  47. 10,
  48. tzinfo=ZoneInfo("Asia/Shanghai"),
  49. )
  50. return determine_performance_cleanup_action(
  51. overrides.pop("creative", self._creative()),
  52. overrides.pop("ad", self._ad()),
  53. creative_created_at=created,
  54. as_of_date=as_of,
  55. impressions=impressions,
  56. cost_fen=cost_fen,
  57. metric_start_date=date(2026, 8, 12),
  58. metric_end_date=date(2026, 8, 18),
  59. config=self.config,
  60. **overrides,
  61. )
  62. def test_new_creative_requires_strict_age_and_impression_boundaries(self):
  63. from tools.creative_rejection_cleanup import PERFORMANCE_NEW_RULE
  64. self.assertIsNone(self._action(age_days=5, impressions=0))
  65. self.assertEqual(
  66. self._action(age_days=6, impressions=99)["cleanup_rule_type"],
  67. PERFORMANCE_NEW_RULE,
  68. )
  69. self.assertEqual(
  70. self._action(age_days=7, impressions=0)["cleanup_rule_type"],
  71. PERFORMANCE_NEW_RULE,
  72. )
  73. self.assertIsNone(self._action(age_days=6, impressions=100))
  74. self.assertIsNone(self._action(age_days=6, impressions=0, cost_fen=1))
  75. self.assertIsNone(
  76. self._action(
  77. age_days=6,
  78. impressions=0,
  79. current_day_cost_fen=1,
  80. )
  81. )
  82. def test_old_creative_matches_both_requested_strict_exposure_branches(self):
  83. from tools.creative_rejection_cleanup import (
  84. PERFORMANCE_OLD_HIGH_RULE,
  85. PERFORMANCE_OLD_LOW_RULE,
  86. )
  87. self.assertEqual(
  88. self._action(age_days=8, impressions=699)["cleanup_rule_type"],
  89. PERFORMANCE_OLD_LOW_RULE,
  90. )
  91. self.assertEqual(
  92. self._action(age_days=8, impressions=701)["cleanup_rule_type"],
  93. PERFORMANCE_OLD_HIGH_RULE,
  94. )
  95. self.assertIsNone(self._action(age_days=8, impressions=700))
  96. self.assertIsNone(self._action(age_days=8, impressions=701, cost_fen=1))
  97. self.assertIsNone(
  98. self._action(
  99. age_days=8,
  100. impressions=701,
  101. current_day_cost_fen=1,
  102. )
  103. )
  104. def test_stopped_or_unapproved_assets_are_never_performance_deleted(self):
  105. self.assertIsNone(
  106. self._action(
  107. age_days=8,
  108. impressions=0,
  109. creative=self._creative(configured_status="AD_STATUS_SUSPEND"),
  110. )
  111. )
  112. self.assertIsNone(
  113. self._action(
  114. age_days=8,
  115. impressions=0,
  116. ad=self._ad(configured_status="AD_STATUS_SUSPEND"),
  117. )
  118. )
  119. self.assertIsNone(
  120. self._action(
  121. age_days=8,
  122. impressions=0,
  123. ad=self._ad(begin_date="2026-08-20"),
  124. )
  125. )
  126. self.assertIsNone(
  127. self._action(
  128. age_days=8,
  129. impressions=0,
  130. creative=self._creative(
  131. creative_set_approval_status=(
  132. "CREATIVE_SET_APPROVAL_STATUS_PENDING"
  133. )
  134. ),
  135. )
  136. )
  137. class CreativePerformanceMetricApiTests(unittest.TestCase):
  138. def test_tencent_metric_query_sums_impressions_and_cost(self):
  139. from tencent_client import TencentClient
  140. response = Mock()
  141. response.raise_for_status.return_value = None
  142. response.json.return_value = {
  143. "code": 0,
  144. "data": {
  145. "list": [
  146. {
  147. "dynamic_creative_id": 3,
  148. "view_count": 40,
  149. "cost": 0,
  150. },
  151. {
  152. "dynamic_creative_id": 3,
  153. "view_count": 59,
  154. "cost": 0,
  155. },
  156. ],
  157. "page_info": {"total_page": 1},
  158. },
  159. }
  160. client = TencentClient()
  161. client.session.get = Mock(return_value=response)
  162. client._common_params = Mock(return_value={"access_token": "token"})
  163. metrics = client.get_dynamic_creative_metrics(
  164. 1,
  165. [3, 4],
  166. date(2026, 8, 12),
  167. date(2026, 8, 18),
  168. )
  169. self.assertEqual(
  170. metrics,
  171. {
  172. 3: {"impressions": 99, "cost_fen": 0},
  173. 4: {"impressions": 0, "cost_fen": 0},
  174. },
  175. )
  176. def test_tencent_ad_metric_query_sums_three_days_and_keeps_zero_rows(self):
  177. from tencent_client import TencentClient
  178. response = Mock()
  179. response.raise_for_status.return_value = None
  180. response.json.return_value = {
  181. "code": 0,
  182. "data": {
  183. "list": [
  184. {"adgroup_id": 2, "cost": 0, "view_count": 10},
  185. {"adgroup_id": 2, "cost": 0, "view_count": 20},
  186. ],
  187. "page_info": {"total_page": 1},
  188. },
  189. }
  190. client = TencentClient()
  191. client.session.get = Mock(return_value=response)
  192. client._common_params = Mock(return_value={"access_token": "token"})
  193. metrics = client.get_ad_metrics(
  194. 1,
  195. [2, 4],
  196. date(2026, 8, 16),
  197. date(2026, 8, 18),
  198. )
  199. self.assertEqual(metrics[2]["cost_fen"], 0)
  200. self.assertEqual(metrics[2]["impressions"], 30)
  201. self.assertEqual(metrics[4]["cost_fen"], 0)
  202. class CreativePerformanceScopeTests(unittest.TestCase):
  203. def test_account_metadata_uses_latest_active_account_row(self):
  204. import pandas as pd
  205. from tools.creative_rejection_cleanup import (
  206. fetch_tencent_account_metadata,
  207. )
  208. client = Mock()
  209. client.execute_sql.return_value = pd.DataFrame([
  210. {
  211. "account_id": "1",
  212. "account_name": " 账户一 ",
  213. "agent_name": " 代理商甲 ",
  214. },
  215. {
  216. "account_id": "2",
  217. "account_name": float("nan"),
  218. "agent_name": float("nan"),
  219. },
  220. ])
  221. metadata = fetch_tencent_account_metadata(client)
  222. sql = " ".join(client.execute_sql.call_args.args[0].split())
  223. self.assertIn("FROM loghubods.ad_put_tencent_account", sql)
  224. self.assertIn("PARTITION BY account_id", sql)
  225. self.assertLess(sql.index("ROW_NUMBER() OVER"), sql.index("WHERE rn=1"))
  226. self.assertIn("WHERE rn=1 AND is_delete=0", sql)
  227. self.assertEqual(
  228. metadata[1],
  229. {"account_name": "账户一", "agent_name": "代理商甲"},
  230. )
  231. self.assertEqual(
  232. metadata[2],
  233. {"account_name": "", "agent_name": ""},
  234. )
  235. def test_inventory_filters_is_delete_after_latest_row_selection(self):
  236. import pandas as pd
  237. from tools.creative_rejection_cleanup import (
  238. fetch_active_creative_inventory,
  239. )
  240. client = Mock()
  241. client.execute_sql.return_value = pd.DataFrame([{
  242. "account_id": 1,
  243. "ad_id": 2,
  244. "creative_id": 3,
  245. "creative_name": "创意三",
  246. "creative_status": "NORMAL",
  247. "create_time": "2026-08-13 10:00:00",
  248. "update_time": "2026-08-18 10:00:00",
  249. }])
  250. inventory = fetch_active_creative_inventory(client)
  251. sql = " ".join(client.execute_sql.call_args.args[0].split())
  252. self.assertEqual(sql.count("is_delete=0"), 1)
  253. self.assertIn(
  254. "ROW_NUMBER() OVER ( PARTITION BY account_id, creative_id",
  255. sql,
  256. )
  257. self.assertLess(sql.index("ROW_NUMBER() OVER"), sql.index("WHERE rn=1"))
  258. self.assertEqual(inventory[0]["account_id"], 1)
  259. self.assertEqual(inventory[0]["adgroup_id"], 2)
  260. self.assertEqual(inventory[0]["creative_id"], 3)
  261. self.assertEqual(inventory[0]["creative_status"], "NORMAL")
  262. self.assertIsNotNone(inventory[0]["create_time"])
  263. def test_scan_uses_only_odps_creatives_and_queries_today_separately(self):
  264. from tools import creative_rejection_cleanup as cleanup
  265. tencent = Mock()
  266. tencent.get_dynamic_creatives.return_value = [
  267. {
  268. "dynamic_creative_id": 3,
  269. "adgroup_id": 2,
  270. "configured_status": "AD_STATUS_NORMAL",
  271. "creative_set_approval_status": (
  272. cleanup.CREATIVE_NORMAL_STATUS
  273. ),
  274. "system_status": "DYNAMIC_CREATIVE_STATUS_NORMAL",
  275. },
  276. {
  277. "dynamic_creative_id": 9,
  278. "adgroup_id": 9,
  279. "configured_status": "AD_STATUS_NORMAL",
  280. "creative_set_approval_status": (
  281. cleanup.CREATIVE_NORMAL_STATUS
  282. ),
  283. "system_status": "DYNAMIC_CREATIVE_STATUS_NORMAL",
  284. },
  285. ]
  286. tencent.get_ads.return_value = [
  287. {
  288. "adgroup_id": 2,
  289. "configured_status": "AD_STATUS_NORMAL",
  290. "created_time": "2026-08-10 00:00:00",
  291. },
  292. {
  293. "adgroup_id": 9,
  294. "configured_status": "AD_STATUS_NORMAL",
  295. "created_time": "2026-08-10 00:00:00",
  296. },
  297. ]
  298. tencent.get_dynamic_creative_metrics.side_effect = [
  299. {3: {"impressions": 99, "cost_fen": 0}},
  300. {3: {"impressions": 1, "cost_fen": 5}},
  301. ]
  302. tencent.get_ad_metrics.side_effect = [
  303. {2: {"impressions": 0, "cost_fen": 0}},
  304. {2: {"impressions": 1, "cost_fen": 7}},
  305. ]
  306. sources = {
  307. 3: {
  308. "account_id": 1,
  309. "adgroup_id": 2,
  310. "creative_id": 3,
  311. "create_time": "2026-08-13 10:00:00",
  312. },
  313. 4: {
  314. "account_id": 1,
  315. "adgroup_id": 4,
  316. "creative_id": 4,
  317. "create_time": "2026-08-13 10:00:00",
  318. },
  319. }
  320. result = cleanup._scan_one_account(
  321. {"account_id": 1},
  322. tencent=tencent,
  323. review_fetcher=Mock(),
  324. spend_start_date=date(2026, 8, 16),
  325. spend_end_date=date(2026, 8, 18),
  326. review_enabled=False,
  327. performance_enabled=True,
  328. performance_source_creatives=sources,
  329. performance_start_date=date(2026, 8, 12),
  330. performance_end_date=date(2026, 8, 18),
  331. current_day_date=date(2026, 8, 19),
  332. ad_cleanup_enabled=True,
  333. ad_metric_start_date=date(2026, 8, 16),
  334. ad_metric_end_date=date(2026, 8, 18),
  335. )
  336. self.assertEqual(result[7][3]["current_day_cost_fen"], 5)
  337. self.assertEqual(result[7][3]["impressions"], 100)
  338. self.assertEqual(result[9][2]["current_day_cost_fen"], 7)
  339. self.assertEqual(result[12]["missing_tencent_creatives"], 1)
  340. creative_calls = tencent.get_dynamic_creative_metrics.call_args_list
  341. self.assertEqual(creative_calls[0].args[1], [3])
  342. self.assertEqual(
  343. creative_calls[0].args[2:],
  344. (date(2026, 8, 13), date(2026, 8, 18)),
  345. )
  346. self.assertEqual(
  347. creative_calls[1].args[2:],
  348. (date(2026, 8, 19), date(2026, 8, 19)),
  349. )
  350. ad_calls = tencent.get_ad_metrics.call_args_list
  351. self.assertEqual(ad_calls[0].args[1], [2])
  352. self.assertEqual(
  353. ad_calls[1].args[2:],
  354. (date(2026, 8, 19), date(2026, 8, 19)),
  355. )
  356. def test_review_failure_does_not_block_independent_ad_metric_scan(self):
  357. from tools import creative_rejection_cleanup as cleanup
  358. tencent = Mock()
  359. tencent.get_dynamic_creatives.return_value = [{
  360. "dynamic_creative_id": 3,
  361. "adgroup_id": 2,
  362. "configured_status": "AD_STATUS_NORMAL",
  363. "creative_set_approval_status": cleanup.CREATIVE_NORMAL_STATUS,
  364. "system_status": "DYNAMIC_CREATIVE_STATUS_NORMAL",
  365. }]
  366. tencent.get_ads.return_value = [{
  367. "adgroup_id": 2,
  368. "configured_status": "AD_STATUS_NORMAL",
  369. "created_time": "2026-08-10 00:00:00",
  370. }]
  371. tencent.get_ad_metrics.return_value = {
  372. 2: {"impressions": 0, "cost_fen": 0}
  373. }
  374. result = cleanup._scan_one_account(
  375. {"account_id": 1},
  376. tencent=tencent,
  377. review_fetcher=Mock(side_effect=RuntimeError("review unavailable")),
  378. spend_start_date=date(2026, 8, 16),
  379. spend_end_date=date(2026, 8, 18),
  380. review_enabled=True,
  381. performance_enabled=True,
  382. performance_source_creatives={
  383. 3: {
  384. "account_id": 1,
  385. "adgroup_id": 2,
  386. "creative_id": 3,
  387. "create_time": "2026-08-13 10:00:00",
  388. }
  389. },
  390. performance_start_date=date(2026, 8, 12),
  391. performance_end_date=date(2026, 8, 18),
  392. performance_as_of=datetime(
  393. 2026,
  394. 8,
  395. 19,
  396. 11,
  397. tzinfo=ZoneInfo("Asia/Shanghai"),
  398. ),
  399. current_day_date=date(2026, 8, 19),
  400. ad_cleanup_enabled=True,
  401. ad_metric_start_date=date(2026, 8, 16),
  402. ad_metric_end_date=date(2026, 8, 18),
  403. )
  404. self.assertEqual(result[11], "review unavailable")
  405. self.assertIsNone(result[10])
  406. self.assertIn(2, result[9])
  407. tencent.get_dynamic_creative_metrics.assert_not_called()
  408. def test_retry_item_that_left_active_inventory_is_skipped_not_deferred(self):
  409. from tools import creative_rejection_cleanup as cleanup
  410. creative_item = {
  411. "account_id": 1,
  412. "adgroup_id": 2,
  413. "dynamic_creative_id": 3,
  414. "cleanup_action": cleanup.DELETE_CREATIVE,
  415. "cleanup_rule_type": cleanup.PERFORMANCE_NEW_RULE,
  416. }
  417. ad_item = {
  418. "account_id": 1,
  419. "adgroup_id": 2,
  420. "dynamic_creative_id": -2,
  421. "cleanup_action": cleanup.DELETE_AD,
  422. "cleanup_rule_type": cleanup.PERFORMANCE_AD_ZERO_SPEND_RULE,
  423. }
  424. creative_result = cleanup.cleanup_precondition_failure(
  425. creative_item,
  426. set(),
  427. {},
  428. performance_scanned_accounts={1},
  429. performance_scope_loaded=True,
  430. performance_source_keys=set(),
  431. )
  432. ad_result = cleanup.cleanup_precondition_failure(
  433. ad_item,
  434. set(),
  435. {},
  436. ad_scanned_accounts={1},
  437. performance_scope_loaded=True,
  438. performance_source_ad_keys=set(),
  439. )
  440. scope_error_result = cleanup.cleanup_precondition_failure(
  441. creative_item,
  442. set(),
  443. {},
  444. performance_scope_loaded=False,
  445. )
  446. self.assertEqual(
  447. creative_result[0], "SKIPPED_REVIEW_NOT_RECONFIRMED"
  448. )
  449. self.assertEqual(ad_result[0], "SKIPPED_REVIEW_NOT_RECONFIRMED")
  450. self.assertEqual(scope_error_result[0], "DEFERRED")
  451. def test_ad_uses_odps_ad_id_even_when_tencent_creative_mapping_differs(self):
  452. from tools import creative_rejection_cleanup as cleanup
  453. tencent = Mock()
  454. tencent.get_dynamic_creatives.return_value = [{
  455. "dynamic_creative_id": 3,
  456. "adgroup_id": 2,
  457. "configured_status": "AD_STATUS_NORMAL",
  458. "creative_set_approval_status": cleanup.CREATIVE_NORMAL_STATUS,
  459. "system_status": "DYNAMIC_CREATIVE_STATUS_NORMAL",
  460. }]
  461. tencent.get_ads.return_value = [{
  462. "adgroup_id": 5,
  463. "configured_status": "AD_STATUS_NORMAL",
  464. "created_time": "2026-08-10 00:00:00",
  465. }]
  466. tencent.get_ad_metrics.return_value = {
  467. 5: {"impressions": 0, "cost_fen": 0}
  468. }
  469. result = cleanup._scan_one_account(
  470. {"account_id": 1},
  471. tencent=tencent,
  472. review_fetcher=Mock(),
  473. spend_start_date=date(2026, 8, 16),
  474. spend_end_date=date(2026, 8, 18),
  475. review_enabled=False,
  476. performance_source_creatives={
  477. 3: {
  478. "account_id": 1,
  479. "adgroup_id": 5,
  480. "creative_id": 3,
  481. "create_time": "2026-08-13 10:00:00",
  482. }
  483. },
  484. current_day_date=date(2026, 8, 19),
  485. ad_cleanup_enabled=True,
  486. ad_metric_start_date=date(2026, 8, 16),
  487. ad_metric_end_date=date(2026, 8, 18),
  488. )
  489. self.assertIn(5, result[9])
  490. self.assertEqual(result[12]["ad_mismatches"], 1)
  491. self.assertEqual(tencent.get_ad_metrics.call_count, 2)
  492. self.assertEqual(tencent.get_ad_metrics.call_args_list[0].args[1], [5])
  493. def test_creative_list_failure_does_not_block_ad_scan(self):
  494. from tools import creative_rejection_cleanup as cleanup
  495. tencent = Mock()
  496. tencent.get_dynamic_creatives.side_effect = RuntimeError(
  497. "creative endpoint unavailable"
  498. )
  499. tencent.get_ads.return_value = [{
  500. "adgroup_id": 2,
  501. "configured_status": "AD_STATUS_NORMAL",
  502. "created_time": "2026-08-10 00:00:00",
  503. }]
  504. tencent.get_ad_metrics.return_value = {
  505. 2: {"impressions": 0, "cost_fen": 0}
  506. }
  507. result = cleanup._scan_one_account(
  508. {"account_id": 1},
  509. tencent=tencent,
  510. review_fetcher=Mock(),
  511. spend_start_date=date(2026, 8, 16),
  512. spend_end_date=date(2026, 8, 18),
  513. review_enabled=True,
  514. performance_enabled=True,
  515. performance_source_creatives={
  516. 3: {
  517. "account_id": 1,
  518. "adgroup_id": 2,
  519. "creative_id": 3,
  520. "create_time": "2026-08-13 10:00:00",
  521. }
  522. },
  523. performance_start_date=date(2026, 8, 12),
  524. performance_end_date=date(2026, 8, 18),
  525. current_day_date=date(2026, 8, 19),
  526. ad_cleanup_enabled=True,
  527. ad_metric_start_date=date(2026, 8, 16),
  528. ad_metric_end_date=date(2026, 8, 18),
  529. )
  530. self.assertIn("creative endpoint unavailable", result[11])
  531. self.assertIn("creative endpoint unavailable", result[8])
  532. self.assertIsNone(result[10])
  533. self.assertIn(2, result[9])
  534. tencent.get_ads.assert_called_once_with(1)
  535. class AdPerformanceRuleTests(unittest.TestCase):
  536. def test_active_ad_with_zero_three_day_spend_is_deleted(self):
  537. from tools.creative_rejection_cleanup import (
  538. DELETE_AD,
  539. PERFORMANCE_AD_ZERO_SPEND_RULE,
  540. determine_ad_performance_cleanup_action,
  541. )
  542. action = determine_ad_performance_cleanup_action(
  543. {
  544. "configured_status": "AD_STATUS_NORMAL",
  545. "begin_date": "2026-08-01",
  546. "end_date": "2026-08-31",
  547. "created_time": "2026-08-10 00:00:00",
  548. },
  549. as_of_date=date(2026, 8, 19),
  550. cost_fen=0,
  551. metric_start_date=date(2026, 8, 16),
  552. metric_end_date=date(2026, 8, 18),
  553. window_days=3,
  554. )
  555. self.assertEqual(action["cleanup_action"], DELETE_AD)
  556. self.assertEqual(
  557. action["cleanup_rule_type"],
  558. PERFORMANCE_AD_ZERO_SPEND_RULE,
  559. )
  560. def test_ad_with_any_spend_or_inactive_status_is_not_deleted(self):
  561. from tools.creative_rejection_cleanup import (
  562. determine_ad_performance_cleanup_action,
  563. )
  564. common = {
  565. "as_of_date": date(2026, 8, 19),
  566. "metric_start_date": date(2026, 8, 16),
  567. "metric_end_date": date(2026, 8, 18),
  568. "window_days": 3,
  569. }
  570. self.assertIsNone(
  571. determine_ad_performance_cleanup_action(
  572. {
  573. "configured_status": "AD_STATUS_NORMAL",
  574. "created_time": "2026-08-10 00:00:00",
  575. },
  576. cost_fen=1,
  577. **common,
  578. )
  579. )
  580. self.assertIsNone(
  581. determine_ad_performance_cleanup_action(
  582. {
  583. "configured_status": "AD_STATUS_SUSPEND",
  584. "created_time": "2026-08-10 00:00:00",
  585. },
  586. cost_fen=0,
  587. **common,
  588. )
  589. )
  590. self.assertIsNone(
  591. determine_ad_performance_cleanup_action(
  592. {
  593. "configured_status": "AD_STATUS_NORMAL",
  594. "created_time": "2026-08-10 00:00:00",
  595. },
  596. cost_fen=0,
  597. current_day_cost_fen=1,
  598. **common,
  599. )
  600. )
  601. def test_ad_requires_more_than_five_complete_days_and_created_time(self):
  602. from tools.creative_rejection_cleanup import (
  603. determine_ad_performance_cleanup_action,
  604. )
  605. as_of = datetime(
  606. 2026,
  607. 8,
  608. 19,
  609. 11,
  610. tzinfo=ZoneInfo("Asia/Shanghai"),
  611. )
  612. common = {
  613. "configured_status": "AD_STATUS_NORMAL",
  614. "begin_date": "2026-08-01",
  615. "end_date": "2026-08-31",
  616. }
  617. kwargs = {
  618. "as_of_date": as_of,
  619. "cost_fen": 0,
  620. "metric_start_date": date(2026, 8, 16),
  621. "metric_end_date": date(2026, 8, 18),
  622. "window_days": 3,
  623. "min_age_days": 5,
  624. }
  625. self.assertIsNone(
  626. determine_ad_performance_cleanup_action(
  627. {**common, "created_time": "2026-08-14 11:00:00"},
  628. **kwargs,
  629. )
  630. )
  631. self.assertIsNone(
  632. determine_ad_performance_cleanup_action(
  633. {**common, "created_time": "2026-08-13 11:01:00"},
  634. **kwargs,
  635. )
  636. )
  637. self.assertIsNotNone(
  638. determine_ad_performance_cleanup_action(
  639. {**common, "created_time": "2026-08-13 11:00:00"},
  640. **kwargs,
  641. )
  642. )
  643. self.assertIsNone(
  644. determine_ad_performance_cleanup_action(common, **kwargs)
  645. )
  646. def test_tencent_created_time_epoch_seconds_are_parsed_in_shanghai(self):
  647. from tools.creative_rejection_cleanup import (
  648. _as_shanghai_datetime,
  649. determine_ad_performance_cleanup_action,
  650. )
  651. created = datetime(
  652. 2026,
  653. 8,
  654. 10,
  655. 9,
  656. 30,
  657. tzinfo=ZoneInfo("Asia/Shanghai"),
  658. )
  659. epoch_seconds = int(created.timestamp())
  660. self.assertEqual(_as_shanghai_datetime(epoch_seconds), created)
  661. self.assertEqual(_as_shanghai_datetime(epoch_seconds * 1000), created)
  662. self.assertEqual(
  663. _as_shanghai_datetime(20260810093000),
  664. created,
  665. )
  666. self.assertIsNone(_as_shanghai_datetime(2))
  667. action = determine_ad_performance_cleanup_action(
  668. {
  669. "configured_status": "AD_STATUS_NORMAL",
  670. "created_time": epoch_seconds,
  671. },
  672. as_of_date=datetime(
  673. 2026,
  674. 8,
  675. 19,
  676. 11,
  677. tzinfo=ZoneInfo("Asia/Shanghai"),
  678. ),
  679. cost_fen=0,
  680. metric_start_date=date(2026, 8, 16),
  681. metric_end_date=date(2026, 8, 18),
  682. )
  683. self.assertEqual(action["creative_created_at"], created)
  684. self.assertNotEqual(action["creative_created_at"].year, 1970)
  685. def test_tencent_ad_fields_include_creation_time_and_deleted_flag(self):
  686. from tencent_client import AD_FIELDS
  687. self.assertIn("created_time", AD_FIELDS)
  688. self.assertIn("is_deleted", AD_FIELDS)
  689. def test_explicit_deleted_ad_states_are_not_cleanup_candidates(self):
  690. from tools.creative_rejection_cleanup import (
  691. determine_ad_performance_cleanup_action,
  692. )
  693. common = {
  694. "configured_status": "AD_STATUS_NORMAL",
  695. "created_time": "2026-08-10 00:00:00",
  696. }
  697. kwargs = {
  698. "as_of_date": date(2026, 8, 19),
  699. "cost_fen": 0,
  700. "metric_start_date": date(2026, 8, 16),
  701. "metric_end_date": date(2026, 8, 18),
  702. "window_days": 3,
  703. }
  704. for deleted_state in (
  705. {"is_deleted": True},
  706. {"is_delete": 1},
  707. {"system_status": "ADGROUP_STATUS_DELETED"},
  708. {"system_status": "SMART_ADGROUP_STATUS_DELETED"},
  709. ):
  710. with self.subTest(deleted_state=deleted_state):
  711. self.assertIsNone(
  712. determine_ad_performance_cleanup_action(
  713. {**common, **deleted_state},
  714. **kwargs,
  715. )
  716. )
  717. def test_get_ad_retries_exact_id_in_deleted_scope(self):
  718. from tencent_client import TencentClient
  719. active_response = Mock()
  720. active_response.raise_for_status.return_value = None
  721. active_response.json.return_value = {
  722. "code": 0,
  723. "data": {"list": []},
  724. }
  725. deleted_response = Mock()
  726. deleted_response.raise_for_status.return_value = None
  727. deleted_response.json.return_value = {
  728. "code": 0,
  729. "data": {
  730. "list": [{
  731. "adgroup_id": 2,
  732. "is_deleted": True,
  733. "system_status": "ADGROUP_STATUS_DELETED",
  734. }]
  735. },
  736. }
  737. client = TencentClient()
  738. client.session.get = Mock(
  739. side_effect=[active_response, deleted_response]
  740. )
  741. client._common_params = Mock(return_value={"access_token": "token"})
  742. result = client.get_ad(1, 2)
  743. self.assertTrue(result["is_deleted"])
  744. self.assertEqual(client.session.get.call_count, 2)
  745. first_params = client.session.get.call_args_list[0].kwargs["params"]
  746. second_params = client.session.get.call_args_list[1].kwargs["params"]
  747. self.assertEqual(
  748. json.loads(first_params["filtering"])[0]["values"],
  749. ["2"],
  750. )
  751. self.assertNotIn("is_deleted", first_params)
  752. self.assertIs(second_params["is_deleted"], True)
  753. def test_cleanup_day_guard_rejects_a_batch_that_crossed_midnight(self):
  754. from tools.creative_rejection_cleanup import _is_current_cleanup_day
  755. self.assertTrue(
  756. _is_current_cleanup_day(
  757. date(2026, 8, 19),
  758. datetime(
  759. 2026,
  760. 8,
  761. 19,
  762. 23,
  763. 59,
  764. tzinfo=ZoneInfo("Asia/Shanghai"),
  765. ),
  766. )
  767. )
  768. self.assertFalse(
  769. _is_current_cleanup_day(
  770. date(2026, 8, 19),
  771. datetime(
  772. 2026,
  773. 8,
  774. 20,
  775. 0,
  776. 1,
  777. tzinfo=ZoneInfo("Asia/Shanghai"),
  778. ),
  779. )
  780. )
  781. def test_tencent_ad_delete_uses_delete_endpoint_and_readback(self):
  782. from tencent_client import TencentClient
  783. response = Mock(status_code=200, text="ok", headers={})
  784. response.raise_for_status.return_value = None
  785. response.json.return_value = {"code": 0, "data": {}}
  786. client = TencentClient()
  787. client.session.post = Mock(return_value=response)
  788. client._common_params = Mock(return_value={"access_token": "token"})
  789. client._user_token = Mock(return_value="user-token")
  790. client.get_ad = Mock(
  791. return_value={
  792. "configured_status": "AD_STATUS_NORMAL",
  793. "system_status": "ADGROUP_STATUS_DELETED",
  794. "is_deleted": False,
  795. }
  796. )
  797. result = client.delete_ad(1, 2)
  798. self.assertEqual(result["system_status"], "ADGROUP_STATUS_DELETED")
  799. request = client.session.post.call_args
  800. self.assertTrue(request.args[0].endswith("/adgroups/delete"))
  801. self.assertEqual(request.kwargs["json"]["adgroup_id"], 2)
  802. def test_tencent_ad_delete_accepts_is_deleted_flag(self):
  803. from tencent_client import TencentClient
  804. response = Mock(status_code=200, text="ok", headers={})
  805. response.raise_for_status.return_value = None
  806. response.json.return_value = {"code": 0, "data": {}}
  807. client = TencentClient()
  808. client.session.post = Mock(return_value=response)
  809. client._common_params = Mock(return_value={"access_token": "token"})
  810. client._user_token = Mock(return_value="user-token")
  811. client.get_ad = Mock(
  812. return_value={
  813. "configured_status": "AD_STATUS_NORMAL",
  814. "system_status": "ADGROUP_STATUS_ACTIVE",
  815. "is_deleted": True,
  816. }
  817. )
  818. result = client.delete_ad(1, 2)
  819. self.assertTrue(result["is_deleted"])
  820. def test_tencent_ad_delete_does_not_infer_deletion_from_missing_row(self):
  821. from tencent_client import PostWriteVerificationError, TencentClient
  822. response = Mock(status_code=200, text="ok", headers={})
  823. response.raise_for_status.return_value = None
  824. response.json.return_value = {"code": 0, "data": {}}
  825. client = TencentClient()
  826. client.verify_attempts = 1
  827. client.session.post = Mock(return_value=response)
  828. client._common_params = Mock(return_value={"access_token": "token"})
  829. client._user_token = Mock(return_value="user-token")
  830. client.get_ad = Mock(
  831. side_effect=RuntimeError(
  832. "Ad not found after update: account=1 adgroup=2"
  833. )
  834. )
  835. with self.assertRaises(PostWriteVerificationError):
  836. client.delete_ad(1, 2)
  837. class CreativePerformanceNotificationTests(unittest.TestCase):
  838. def test_internal_only_agency_uses_internal_route_without_webhook(self):
  839. from tools import creative_rejection_cleanup as cleanup
  840. with patch.dict(
  841. os.environ,
  842. {
  843. "CREATIVE_CLEANUP_INTERNAL_ONLY_AGENCIES": "小程序-自动化",
  844. },
  845. clear=False,
  846. ):
  847. self.assertTrue(
  848. cleanup._has_cleanup_notification_route("小程序-自动化", {})
  849. )
  850. self.assertFalse(
  851. cleanup._has_cleanup_notification_route("其他代理", {})
  852. )
  853. def test_preview_notification_excludes_candidates_not_confirmed_this_run(self):
  854. from tools import creative_rejection_cleanup as cleanup
  855. current_row = {
  856. "id": 1,
  857. "check_date": date(2026, 8, 20),
  858. "account_id": 1,
  859. "dynamic_creative_id": 3,
  860. "cleanup_rule_type": cleanup.PERFORMANCE_NEW_RULE,
  861. "operator_notified_at": datetime(2026, 8, 20, 11),
  862. }
  863. current_ad_row = {
  864. "id": 2,
  865. "check_date": date(2026, 8, 20),
  866. "account_id": 1,
  867. "dynamic_creative_id": -2,
  868. "cleanup_rule_type": cleanup.PERFORMANCE_AD_ZERO_SPEND_RULE,
  869. "creative_created_at": datetime(2026, 8, 10, 10),
  870. "operator_notified_at": None,
  871. }
  872. stale_bad_timestamp_row = {
  873. **current_ad_row,
  874. "id": 3,
  875. "dynamic_creative_id": -4,
  876. "creative_created_at": datetime(1970, 1, 1, 0, 0, 2),
  877. }
  878. selected = cleanup.filter_preview_notification_rows(
  879. [current_row, current_ad_row, stale_bad_timestamp_row],
  880. check_date=date(2026, 8, 20),
  881. confirmed_actions={
  882. (1, 3): {
  883. "cleanup_rule_type": cleanup.PERFORMANCE_NEW_RULE,
  884. },
  885. (1, -2): {
  886. "cleanup_rule_type": (
  887. cleanup.PERFORMANCE_AD_ZERO_SPEND_RULE
  888. ),
  889. }
  890. },
  891. force_notification=True,
  892. )
  893. self.assertEqual([row["id"] for row in selected], [1, 2])
  894. self.assertIsNone(selected[0]["operator_notified_at"])
  895. def test_performance_rows_only_enter_the_separate_internal_delivery(self):
  896. from tools.creative_rejection_cleanup import (
  897. PERFORMANCE_AD_ZERO_SPEND_RULE,
  898. PERFORMANCE_NEW_RULE,
  899. REVIEW_DENIED_RULE,
  900. split_notification_rows,
  901. )
  902. review_row = {
  903. "id": 1,
  904. "agency_name": "代理一",
  905. "cleanup_rule_type": REVIEW_DENIED_RULE,
  906. "agency_notified_at": None,
  907. "operator_notified_at": None,
  908. }
  909. performance_row = {
  910. "id": 2,
  911. "agency_name": "",
  912. "cleanup_rule_type": PERFORMANCE_NEW_RULE,
  913. "agency_notified_at": datetime(2026, 8, 19, 10),
  914. "operator_notified_at": None,
  915. }
  916. ad_row = {
  917. "id": 3,
  918. "agency_name": "",
  919. "cleanup_rule_type": PERFORMANCE_AD_ZERO_SPEND_RULE,
  920. "agency_notified_at": datetime(2026, 8, 19, 10),
  921. "operator_notified_at": None,
  922. }
  923. internal_only_review_row = {
  924. "id": 4,
  925. "agency_name": "小程序-自动化",
  926. "cleanup_rule_type": REVIEW_DENIED_RULE,
  927. "agency_notified_at": None,
  928. "operator_notified_at": None,
  929. }
  930. with patch.dict(
  931. os.environ,
  932. {
  933. "CREATIVE_CLEANUP_INTERNAL_ONLY_AGENCIES": "小程序-自动化",
  934. },
  935. clear=False,
  936. ):
  937. agency, review_internal, performance_internal = split_notification_rows(
  938. [review_row, performance_row, ad_row, internal_only_review_row]
  939. )
  940. self.assertEqual(agency, [review_row])
  941. self.assertEqual(review_internal, [review_row, internal_only_review_row])
  942. self.assertEqual(performance_internal, [performance_row, ad_row])
  943. legacy_agency_rows = [
  944. row
  945. for row in [review_row, performance_row, ad_row]
  946. if row.get("agency_notified_at") is None
  947. and str(row.get("agency_name") or "").strip()
  948. ]
  949. self.assertEqual(legacy_agency_rows, [review_row])
  950. def test_performance_upsert_atomically_suppresses_agency_delivery(self):
  951. from tools import creative_rejection_cleanup as cleanup
  952. class Cursor:
  953. def __init__(self):
  954. self.calls = []
  955. def __enter__(self):
  956. return self
  957. def __exit__(self, *_args):
  958. return None
  959. def execute(self, sql, params=None):
  960. self.calls.append((sql, params))
  961. def fetchone(self):
  962. return {"id": 7}
  963. cursor = Cursor()
  964. connection = Mock()
  965. connection.cursor.return_value = cursor
  966. with patch.object(cleanup, "get_connection", return_value=connection):
  967. cleanup.upsert_cleanup_candidate({
  968. "account_id": 1,
  969. "account_name": "账户一",
  970. "agency_name": "代理一",
  971. "adgroup_id": 2,
  972. "adgroup_name": "广告二",
  973. "dynamic_creative_id": 3,
  974. "dynamic_creative_name": "创意三",
  975. "check_date": date(2026, 8, 19),
  976. "cleanup_action": cleanup.DELETE_CREATIVE,
  977. "cleanup_rule_type": cleanup.PERFORMANCE_NEW_RULE,
  978. "component_ids": [],
  979. "element_ids": [],
  980. "recent_cost_fen": 0,
  981. "cost_start_date": date(2026, 8, 12),
  982. "cost_end_date": date(2026, 8, 18),
  983. "action_reason": "未起量",
  984. "reject_reason": "",
  985. "review_result": {},
  986. "pre_state": {},
  987. })
  988. insert_sql, insert_params = cursor.calls[0]
  989. self.assertEqual(insert_params[3], "")
  990. self.assertEqual(insert_params[10], cleanup.PERFORMANCE_NEW_RULE)
  991. self.assertIsInstance(insert_params[-1], datetime)
  992. self.assertIn("agency_notified_at", insert_sql)
  993. self.assertIn("VALUES(cleanup_rule_type)", insert_sql)
  994. normalized_insert_sql = " ".join(insert_sql.split())
  995. terminal_guard = (
  996. "WHEN cleanup_status IN ( "
  997. "'DELETING','CREATIVE_DELETED','AD_DELETED' )"
  998. )
  999. self.assertEqual(normalized_insert_sql.count(terminal_guard), 2)
  1000. self.assertIn(f"{terminal_guard} THEN agency_name", normalized_insert_sql)
  1001. self.assertIn(
  1002. f"{terminal_guard} THEN agency_notified_at",
  1003. normalized_insert_sql,
  1004. )
  1005. self.assertIn("performance_suppression_at", cursor.calls[1][0])
  1006. self.assertIn("performance_suppression_at", cursor.calls[2][0])
  1007. extended_sql = cursor.calls[2][0]
  1008. self.assertIn(
  1009. "item.creative_created_at <=> incoming.creative_created_at",
  1010. extended_sql,
  1011. )
  1012. self.assertIn(
  1013. "item.agent_name <=> COALESCE(NULLIF(incoming.agent_name,''), item.agent_name)",
  1014. extended_sql,
  1015. )
  1016. def test_schema_backfill_suppresses_existing_performance_rows(self):
  1017. from storage import _suppress_performance_agency_delivery
  1018. cursor = Mock()
  1019. _suppress_performance_agency_delivery(cursor)
  1020. sql = " ".join(cursor.execute.call_args.args[0].split())
  1021. self.assertIn("SET agency_name=''", sql)
  1022. self.assertIn(
  1023. "agency_notified_at=COALESCE(agency_notified_at, NOW())",
  1024. sql,
  1025. )
  1026. self.assertIn(
  1027. "WHERE LEFT(cleanup_rule_type, 12)='PERFORMANCE_'",
  1028. sql,
  1029. )
  1030. def test_schema_backfill_preserves_legacy_partial_review_rule(self):
  1031. from storage import _backfill_legacy_review_rule_types
  1032. cursor = Mock()
  1033. _backfill_legacy_review_rule_types(cursor)
  1034. sql = " ".join(cursor.execute.call_args.args[0].split())
  1035. self.assertIn("SET cleanup_rule_type='REVIEW_PARTIAL'", sql)
  1036. self.assertIn("cleanup_action='ALERT_ONLY'", sql)
  1037. self.assertIn(
  1038. "CREATIVE_SET_APPROVAL_STATUS_PARTIAL_NORMAL",
  1039. sql,
  1040. )
  1041. def test_performance_report_has_its_own_internal_title_and_sheet(self):
  1042. from openpyxl import load_workbook
  1043. from tools.creative_rejection_cleanup import (
  1044. DELETE_AD,
  1045. PERFORMANCE_AD_ZERO_SPEND_RULE,
  1046. PERFORMANCE_NEW_RULE,
  1047. write_performance_operator_summary,
  1048. )
  1049. row = {
  1050. "id": 2,
  1051. "agency_name": "代理一",
  1052. "account_id": 1,
  1053. "account_name": "账户一",
  1054. "agent_name": "代理商甲",
  1055. "adgroup_id": 2,
  1056. "dynamic_creative_id": 3,
  1057. "cleanup_action": "DELETE_CREATIVE",
  1058. "cleanup_rule_type": PERFORMANCE_NEW_RULE,
  1059. "cleanup_status": "DISCOVERED",
  1060. "reject_reason": "",
  1061. "review_result_json": "{}",
  1062. "pre_state_json": "{}",
  1063. "creative_age_days": 6,
  1064. "metric_impressions": 99,
  1065. "metric_daily_avg_impressions": 99 / 7,
  1066. "recent_cost_fen": 0,
  1067. "cost_start_date": "2026-08-12",
  1068. "cost_end_date": "2026-08-18",
  1069. }
  1070. ad_row = {
  1071. "id": 3,
  1072. "agency_name": "",
  1073. "account_id": 1,
  1074. "account_name": "账户一",
  1075. "agent_name": "代理商甲",
  1076. "adgroup_id": 2,
  1077. "adgroup_name": "广告二",
  1078. "dynamic_creative_id": -2,
  1079. "dynamic_creative_name": "",
  1080. "cleanup_action": DELETE_AD,
  1081. "cleanup_rule_type": PERFORMANCE_AD_ZERO_SPEND_RULE,
  1082. "cleanup_status": "DISCOVERED",
  1083. "reject_reason": "",
  1084. "review_result_json": "{}",
  1085. "pre_state_json": '{"configured_status":"AD_STATUS_NORMAL"}',
  1086. "recent_cost_fen": 0,
  1087. "metric_window_days": 3,
  1088. "cost_start_date": "2026-08-16",
  1089. "cost_end_date": "2026-08-18",
  1090. }
  1091. with TemporaryDirectory() as directory:
  1092. report = write_performance_operator_summary(
  1093. [row, ad_row],
  1094. Path(directory),
  1095. "20260819",
  1096. )
  1097. workbook = load_workbook(report["report"])
  1098. self.assertEqual(
  1099. report["title"],
  1100. "长期未起量创意及广告清理汇总通知",
  1101. )
  1102. self.assertEqual(report["notification_kind"], "performance_internal")
  1103. self.assertLessEqual(len(str(report["report_version"])), 64)
  1104. self.assertEqual(report["creative_rows"], 1)
  1105. self.assertEqual(report["ad_rows"], 1)
  1106. self.assertIn("长期未起量清理", workbook.sheetnames)
  1107. sheet = workbook["长期未起量清理"]
  1108. self.assertEqual(sheet["A1"].value, "清理对象")
  1109. self.assertEqual(sheet["D1"].value, "代理商昵称")
  1110. self.assertEqual([sheet["D2"].value, sheet["D3"].value], ["代理商甲"] * 2)
  1111. self.assertEqual([sheet["A2"].value, sheet["A3"].value], ["创意", "广告"])
  1112. class CreativePerformanceIntegrationTests(unittest.TestCase):
  1113. def setUp(self):
  1114. flags = patch.dict(
  1115. os.environ,
  1116. {
  1117. "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "0",
  1118. "DAILY_UNDERPERFORMING_CREATIVE_CLEANUP_ENABLED": "0",
  1119. "DAILY_UNDERPERFORMING_CREATIVE_APPLY_ENABLED": "0",
  1120. "DAILY_UNDERPERFORMING_AD_CLEANUP_ENABLED": "0",
  1121. "DAILY_UNDERPERFORMING_AD_APPLY_ENABLED": "0",
  1122. },
  1123. clear=False,
  1124. )
  1125. flags.start()
  1126. self.addCleanup(flags.stop)
  1127. account_metadata = patch(
  1128. "tools.creative_rejection_cleanup.fetch_tencent_account_metadata",
  1129. return_value={},
  1130. )
  1131. account_metadata.start()
  1132. self.addCleanup(account_metadata.stop)
  1133. def test_ad_candidate_is_created_after_creative_judgment(self):
  1134. import pandas as pd
  1135. from tools import creative_rejection_cleanup as cleanup
  1136. current = datetime(2026, 8, 19, 11, tzinfo=ZoneInfo("Asia/Shanghai"))
  1137. tencent = Mock()
  1138. tencent.get_dynamic_creatives.return_value = [
  1139. {
  1140. "dynamic_creative_id": 3,
  1141. "dynamic_creative_name": "创意三",
  1142. "adgroup_id": 2,
  1143. "configured_status": "AD_STATUS_NORMAL",
  1144. "creative_set_approval_status": (
  1145. "CREATIVE_SET_APPROVAL_STATUS_NORMAL"
  1146. ),
  1147. "system_status": "DYNAMIC_CREATIVE_STATUS_NORMAL",
  1148. },
  1149. {
  1150. "dynamic_creative_id": 4,
  1151. "dynamic_creative_name": "拒审创意四",
  1152. "adgroup_id": 2,
  1153. "configured_status": "AD_STATUS_NORMAL",
  1154. "creative_set_approval_status": (
  1155. "CREATIVE_SET_APPROVAL_STATUS_DENIED"
  1156. ),
  1157. "system_status": "DYNAMIC_CREATIVE_STATUS_DENIED",
  1158. },
  1159. {
  1160. "dynamic_creative_id": 5,
  1161. "dynamic_creative_name": "已删除创意五",
  1162. "adgroup_id": 2,
  1163. "configured_status": "AD_STATUS_DELETED",
  1164. "creative_set_approval_status": (
  1165. "CREATIVE_SET_APPROVAL_STATUS_DENIED"
  1166. ),
  1167. "system_status": "DYNAMIC_CREATIVE_STATUS_DELETED",
  1168. },
  1169. ]
  1170. tencent.get_ads.return_value = [{
  1171. "adgroup_id": 2,
  1172. "adgroup_name": "广告二",
  1173. "configured_status": "AD_STATUS_NORMAL",
  1174. "created_time": "2026-08-10 00:00:00",
  1175. }]
  1176. tencent.get_dynamic_creative_metrics.side_effect = [
  1177. {3: {"impressions": 99, "cost_fen": 0}},
  1178. {3: {"impressions": 0, "cost_fen": 0}},
  1179. ]
  1180. tencent.get_ad_metrics.return_value = {
  1181. 2: {"impressions": 0, "cost_fen": 0}
  1182. }
  1183. captured = []
  1184. with TemporaryDirectory() as directory, patch.dict(
  1185. os.environ,
  1186. {
  1187. "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "0",
  1188. "DAILY_UNDERPERFORMING_CREATIVE_CLEANUP_ENABLED": "1",
  1189. "DAILY_UNDERPERFORMING_CREATIVE_APPLY_ENABLED": "0",
  1190. "DAILY_UNDERPERFORMING_AD_CLEANUP_ENABLED": "1",
  1191. "DAILY_UNDERPERFORMING_AD_APPLY_ENABLED": "0",
  1192. "ROI_AGENCY_WEBHOOK_ENABLED": "0",
  1193. "FEISHU_AD_PROJECT_CHAT_ID": "chat-internal",
  1194. },
  1195. clear=False,
  1196. ), patch.object(cleanup, "initialize_schema"), patch.object(
  1197. cleanup, "resolve_end_date", return_value="20260818"
  1198. ), patch.object(
  1199. cleanup, "fetch_daily_data", return_value=pd.DataFrame()
  1200. ), patch.object(
  1201. cleanup,
  1202. "fetch_recent_spend_accounts",
  1203. return_value=[{"account_id": 1, "account_name": "账户一"}],
  1204. ), patch.object(
  1205. cleanup, "fetch_account_agency_fallbacks", return_value={}
  1206. ), patch.object(
  1207. cleanup,
  1208. "fetch_active_creative_inventory",
  1209. return_value=[{
  1210. "account_id": 1,
  1211. "adgroup_id": 2,
  1212. "creative_id": 3,
  1213. "create_time": datetime(
  1214. 2026,
  1215. 8,
  1216. 13,
  1217. 10,
  1218. tzinfo=ZoneInfo("Asia/Shanghai"),
  1219. ),
  1220. }],
  1221. ), patch.object(
  1222. cleanup, "prefetch_account_access_tokens", return_value={1: "token"}
  1223. ), patch.object(
  1224. cleanup,
  1225. "upsert_cleanup_candidate",
  1226. side_effect=lambda record: captured.append(record) or {"id": 1},
  1227. ), patch.object(
  1228. cleanup, "load_retryable_cleanup_items", return_value=[]
  1229. ), patch.object(
  1230. cleanup, "load_unnotified_deleted_items", return_value=[]
  1231. ):
  1232. summary = cleanup.run_rejected_creative_cleanup(
  1233. output_dir=Path(directory),
  1234. now=current,
  1235. tencent=tencent,
  1236. odps=Mock(),
  1237. review_fetcher=Mock(return_value=[]),
  1238. )
  1239. self.assertEqual(
  1240. [record["cleanup_rule_type"] for record in captured],
  1241. [
  1242. cleanup.REVIEW_DENIED_RULE,
  1243. cleanup.PERFORMANCE_NEW_RULE,
  1244. cleanup.PERFORMANCE_AD_ZERO_SPEND_RULE,
  1245. ],
  1246. )
  1247. self.assertEqual(captured[2]["dynamic_creative_id"], -2)
  1248. self.assertNotIn(5, [record["dynamic_creative_id"] for record in captured])
  1249. self.assertEqual(summary["rejected_discovered"], 1)
  1250. self.assertEqual(summary["ad_discovered"], 1)
  1251. def test_combined_task_discovers_performance_candidate_without_applying(self):
  1252. import pandas as pd
  1253. from tools import creative_rejection_cleanup as cleanup
  1254. current = datetime(2026, 8, 19, 11, tzinfo=ZoneInfo("Asia/Shanghai"))
  1255. tencent = Mock()
  1256. tencent.get_dynamic_creatives.return_value = [
  1257. {
  1258. "dynamic_creative_id": 3,
  1259. "dynamic_creative_name": "创意三",
  1260. "adgroup_id": 2,
  1261. "configured_status": "AD_STATUS_NORMAL",
  1262. "creative_set_approval_status": (
  1263. "CREATIVE_SET_APPROVAL_STATUS_NORMAL"
  1264. ),
  1265. "system_status": "DYNAMIC_CREATIVE_STATUS_NORMAL",
  1266. }
  1267. ]
  1268. tencent.get_ads.return_value = [
  1269. {
  1270. "adgroup_id": 2,
  1271. "adgroup_name": "广告二",
  1272. "configured_status": "AD_STATUS_NORMAL",
  1273. }
  1274. ]
  1275. tencent.get_dynamic_creative_metrics.side_effect = [
  1276. {3: {"impressions": 99, "cost_fen": 0}},
  1277. {3: {"impressions": 0, "cost_fen": 0}},
  1278. ]
  1279. review_fetcher = Mock(return_value=[])
  1280. captured = []
  1281. with TemporaryDirectory() as directory, patch.dict(
  1282. os.environ,
  1283. {
  1284. "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "0",
  1285. "DAILY_UNDERPERFORMING_CREATIVE_CLEANUP_ENABLED": "1",
  1286. "DAILY_UNDERPERFORMING_CREATIVE_APPLY_ENABLED": "0",
  1287. "ROI_AGENCY_WEBHOOK_ENABLED": "0",
  1288. "FEISHU_AD_PROJECT_CHAT_ID": "chat-internal",
  1289. },
  1290. clear=False,
  1291. ), patch.object(cleanup, "initialize_schema"), patch.object(
  1292. cleanup, "resolve_end_date", return_value="20260818"
  1293. ), patch.object(
  1294. cleanup, "fetch_daily_data", return_value=pd.DataFrame()
  1295. ), patch.object(
  1296. cleanup,
  1297. "fetch_recent_spend_accounts",
  1298. return_value=[],
  1299. ), patch.object(
  1300. cleanup, "fetch_account_agency_fallbacks", return_value={}
  1301. ), patch.object(
  1302. cleanup,
  1303. "fetch_active_creative_inventory",
  1304. return_value=[{
  1305. "account_id": 1,
  1306. "adgroup_id": 2,
  1307. "creative_id": 3,
  1308. "create_time": datetime(
  1309. 2026,
  1310. 8,
  1311. 13,
  1312. 10,
  1313. tzinfo=ZoneInfo("Asia/Shanghai"),
  1314. ),
  1315. }],
  1316. ), patch.object(
  1317. cleanup,
  1318. "fetch_tencent_account_metadata",
  1319. return_value={
  1320. 1: {
  1321. "account_name": "大数据账户一",
  1322. "agent_name": "代理商甲",
  1323. }
  1324. },
  1325. ), patch.object(
  1326. cleanup,
  1327. "prefetch_account_access_tokens",
  1328. return_value={1: "access-token"},
  1329. ), patch.object(
  1330. cleanup,
  1331. "upsert_cleanup_candidate",
  1332. side_effect=lambda record: captured.append(record) or {"id": 1},
  1333. ), patch.object(
  1334. cleanup, "load_retryable_cleanup_items", return_value=[]
  1335. ), patch.object(
  1336. cleanup, "load_unnotified_deleted_items", return_value=[]
  1337. ):
  1338. summary = cleanup.run_rejected_creative_cleanup(
  1339. output_dir=Path(directory),
  1340. now=current,
  1341. tencent=tencent,
  1342. odps=Mock(),
  1343. review_fetcher=review_fetcher,
  1344. )
  1345. self.assertEqual(len(captured), 1)
  1346. self.assertEqual(
  1347. captured[0]["cleanup_rule_type"],
  1348. cleanup.PERFORMANCE_NEW_RULE,
  1349. )
  1350. self.assertEqual(captured[0]["account_name"], "大数据账户一")
  1351. self.assertEqual(captured[0]["agent_name"], "代理商甲")
  1352. self.assertEqual(summary["performance_discovered"], 1)
  1353. self.assertEqual(summary["rejected_discovered"], 0)
  1354. self.assertEqual(summary["review_account_ids"], [])
  1355. self.assertEqual(summary["performance_account_ids"], [1])
  1356. review_fetcher.assert_not_called()
  1357. tencent.delete_dynamic_creative.assert_not_called()
  1358. def test_current_day_spend_blocks_creative_and_ad_candidates(self):
  1359. import pandas as pd
  1360. from tools import creative_rejection_cleanup as cleanup
  1361. current = datetime(2026, 8, 19, 11, tzinfo=ZoneInfo("Asia/Shanghai"))
  1362. tencent = Mock()
  1363. tencent.get_dynamic_creatives.return_value = [{
  1364. "dynamic_creative_id": 3,
  1365. "dynamic_creative_name": "创意三",
  1366. "adgroup_id": 2,
  1367. "configured_status": "AD_STATUS_NORMAL",
  1368. "creative_set_approval_status": cleanup.CREATIVE_NORMAL_STATUS,
  1369. "system_status": "DYNAMIC_CREATIVE_STATUS_NORMAL",
  1370. }]
  1371. tencent.get_ads.return_value = [{
  1372. "adgroup_id": 2,
  1373. "adgroup_name": "广告二",
  1374. "configured_status": "AD_STATUS_NORMAL",
  1375. "created_time": "2026-08-10 00:00:00",
  1376. }]
  1377. tencent.get_dynamic_creative_metrics.side_effect = [
  1378. {3: {"impressions": 99, "cost_fen": 0}},
  1379. {3: {"impressions": 1, "cost_fen": 1}},
  1380. ]
  1381. tencent.get_ad_metrics.side_effect = [
  1382. {2: {"impressions": 0, "cost_fen": 0}},
  1383. {2: {"impressions": 1, "cost_fen": 1}},
  1384. ]
  1385. captured = []
  1386. with TemporaryDirectory() as directory, patch.dict(
  1387. os.environ,
  1388. {
  1389. "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "0",
  1390. "DAILY_UNDERPERFORMING_CREATIVE_CLEANUP_ENABLED": "1",
  1391. "DAILY_UNDERPERFORMING_CREATIVE_APPLY_ENABLED": "0",
  1392. "DAILY_UNDERPERFORMING_AD_CLEANUP_ENABLED": "1",
  1393. "DAILY_UNDERPERFORMING_AD_APPLY_ENABLED": "0",
  1394. "ROI_AGENCY_WEBHOOK_ENABLED": "0",
  1395. "FEISHU_AD_PROJECT_CHAT_ID": "chat-internal",
  1396. },
  1397. clear=False,
  1398. ), patch.object(cleanup, "initialize_schema"), patch.object(
  1399. cleanup, "resolve_end_date", return_value="20260818"
  1400. ), patch.object(
  1401. cleanup, "fetch_daily_data", return_value=pd.DataFrame()
  1402. ), patch.object(
  1403. cleanup, "fetch_recent_spend_accounts", return_value=[]
  1404. ), patch.object(
  1405. cleanup, "fetch_account_agency_fallbacks", return_value={}
  1406. ), patch.object(
  1407. cleanup,
  1408. "fetch_active_creative_inventory",
  1409. return_value=[{
  1410. "account_id": 1,
  1411. "adgroup_id": 2,
  1412. "creative_id": 3,
  1413. "create_time": datetime(
  1414. 2026,
  1415. 8,
  1416. 13,
  1417. 10,
  1418. tzinfo=ZoneInfo("Asia/Shanghai"),
  1419. ),
  1420. }],
  1421. ), patch.object(
  1422. cleanup, "prefetch_account_access_tokens", return_value={1: "token"}
  1423. ), patch.object(
  1424. cleanup,
  1425. "upsert_cleanup_candidate",
  1426. side_effect=lambda record: captured.append(record) or {"id": 1},
  1427. ), patch.object(
  1428. cleanup, "load_retryable_cleanup_items", return_value=[]
  1429. ), patch.object(
  1430. cleanup, "load_unnotified_deleted_items", return_value=[]
  1431. ):
  1432. summary = cleanup.run_rejected_creative_cleanup(
  1433. output_dir=Path(directory),
  1434. now=current,
  1435. tencent=tencent,
  1436. odps=Mock(),
  1437. review_fetcher=Mock(return_value=[]),
  1438. )
  1439. self.assertEqual(captured, [])
  1440. self.assertEqual(summary["performance_discovered"], 0)
  1441. self.assertEqual(summary["ad_discovered"], 0)
  1442. self.assertEqual(summary["current_day_metric_date"], date(2026, 8, 19))
  1443. def test_review_sources_and_creative_endpoint_can_fail_while_ad_continues(self):
  1444. from tools import creative_rejection_cleanup as cleanup
  1445. current = datetime(2026, 8, 19, 11, tzinfo=ZoneInfo("Asia/Shanghai"))
  1446. tencent = Mock()
  1447. tencent.get_dynamic_creatives.side_effect = RuntimeError(
  1448. "creative endpoint unavailable"
  1449. )
  1450. tencent.get_ads.return_value = [{
  1451. "adgroup_id": 2,
  1452. "adgroup_name": "广告二",
  1453. "configured_status": "AD_STATUS_NORMAL",
  1454. "created_time": "2026-08-10 00:00:00",
  1455. }]
  1456. tencent.get_ad_metrics.return_value = {
  1457. 2: {"impressions": 0, "cost_fen": 0}
  1458. }
  1459. captured = []
  1460. with TemporaryDirectory() as directory, patch.dict(
  1461. os.environ,
  1462. {
  1463. "DAILY_UNDERPERFORMING_CREATIVE_CLEANUP_ENABLED": "1",
  1464. "DAILY_UNDERPERFORMING_AD_CLEANUP_ENABLED": "1",
  1465. "ROI_AGENCY_WEBHOOK_ENABLED": "0",
  1466. "FEISHU_AD_PROJECT_CHAT_ID": "chat-internal",
  1467. },
  1468. clear=False,
  1469. ), patch.object(cleanup, "initialize_schema"), patch.object(
  1470. cleanup,
  1471. "resolve_end_date",
  1472. side_effect=RuntimeError("review source unavailable"),
  1473. ), patch.object(
  1474. cleanup,
  1475. "fetch_active_creative_inventory",
  1476. return_value=[{
  1477. "account_id": 1,
  1478. "adgroup_id": 2,
  1479. "creative_id": 3,
  1480. "create_time": datetime(
  1481. 2026,
  1482. 8,
  1483. 13,
  1484. 10,
  1485. tzinfo=ZoneInfo("Asia/Shanghai"),
  1486. ),
  1487. }],
  1488. ), patch.object(
  1489. cleanup, "prefetch_account_access_tokens", return_value={1: "token"}
  1490. ), patch.object(
  1491. cleanup,
  1492. "upsert_cleanup_candidate",
  1493. side_effect=lambda record: captured.append(record) or {"id": 1},
  1494. ), patch.object(
  1495. cleanup, "load_retryable_cleanup_items", return_value=[]
  1496. ), patch.object(
  1497. cleanup, "load_unnotified_deleted_items", return_value=[]
  1498. ):
  1499. summary = cleanup.run_rejected_creative_cleanup(
  1500. output_dir=Path(directory),
  1501. now=current,
  1502. tencent=tencent,
  1503. odps=Mock(),
  1504. )
  1505. self.assertEqual(
  1506. [row["cleanup_rule_type"] for row in captured],
  1507. [cleanup.PERFORMANCE_AD_ZERO_SPEND_RULE],
  1508. )
  1509. self.assertEqual(summary["ad_discovered"], 1)
  1510. self.assertEqual(summary["performance_discovered"], 1)
  1511. self.assertIn("review source unavailable", summary["review_scope_error"])
  1512. self.assertTrue(summary["scan_errors"])
  1513. tencent.get_dynamic_creative_metrics.assert_not_called()
  1514. def test_performance_apply_does_not_require_agency_route(self):
  1515. import pandas as pd
  1516. from tools import creative_rejection_cleanup as cleanup
  1517. @contextmanager
  1518. def acquired_lock(_name):
  1519. yield True
  1520. current = datetime(2026, 8, 19, 11, tzinfo=ZoneInfo("Asia/Shanghai"))
  1521. created_at = datetime(
  1522. 2026,
  1523. 8,
  1524. 13,
  1525. 10,
  1526. tzinfo=ZoneInfo("Asia/Shanghai"),
  1527. )
  1528. creative = {
  1529. "dynamic_creative_id": 3,
  1530. "dynamic_creative_name": "创意三",
  1531. "adgroup_id": 2,
  1532. "configured_status": "AD_STATUS_NORMAL",
  1533. "creative_set_approval_status": (
  1534. "CREATIVE_SET_APPROVAL_STATUS_NORMAL"
  1535. ),
  1536. "system_status": "DYNAMIC_CREATIVE_STATUS_NORMAL",
  1537. }
  1538. ad = {
  1539. "adgroup_id": 2,
  1540. "adgroup_name": "广告二",
  1541. "configured_status": "AD_STATUS_NORMAL",
  1542. }
  1543. tencent = Mock()
  1544. tencent.get_dynamic_creatives.return_value = [creative]
  1545. tencent.get_ads.return_value = [ad]
  1546. tencent.get_dynamic_creative.return_value = creative
  1547. tencent.get_ad.return_value = ad
  1548. tencent.get_dynamic_creative_metrics.side_effect = [
  1549. {3: {"impressions": 99, "cost_fen": 0}},
  1550. {3: {"impressions": 0, "cost_fen": 0}},
  1551. {3: {"impressions": 99, "cost_fen": 0}},
  1552. {3: {"impressions": 0, "cost_fen": 0}},
  1553. ]
  1554. tencent.delete_dynamic_creative.return_value = {
  1555. "dynamic_creative_id": 3,
  1556. "configured_status": "AD_STATUS_DELETED",
  1557. }
  1558. retry_item = {
  1559. "id": 7,
  1560. "account_id": 1,
  1561. "account_name": "账户一",
  1562. "agency_name": "",
  1563. "adgroup_id": 2,
  1564. "adgroup_name": "广告二",
  1565. "dynamic_creative_id": 3,
  1566. "dynamic_creative_name": "创意三",
  1567. "cleanup_action": cleanup.DELETE_CREATIVE,
  1568. "cleanup_rule_type": cleanup.PERFORMANCE_NEW_RULE,
  1569. "cleanup_status": "DISCOVERED",
  1570. "creative_created_at": created_at,
  1571. }
  1572. with TemporaryDirectory() as directory, patch.dict(
  1573. os.environ,
  1574. {
  1575. "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "0",
  1576. "DAILY_UNDERPERFORMING_CREATIVE_CLEANUP_ENABLED": "1",
  1577. "DAILY_UNDERPERFORMING_CREATIVE_APPLY_ENABLED": "1",
  1578. "ROI_AGENCY_WEBHOOK_ENABLED": "0",
  1579. "FEISHU_AD_PROJECT_CHAT_ID": "chat-internal",
  1580. },
  1581. clear=False,
  1582. ), patch.object(cleanup, "initialize_schema"), patch.object(
  1583. cleanup, "resolve_end_date", return_value="20260818"
  1584. ), patch.object(
  1585. cleanup, "fetch_daily_data", return_value=pd.DataFrame()
  1586. ), patch.object(
  1587. cleanup, "fetch_recent_spend_accounts", return_value=[]
  1588. ), patch.object(
  1589. cleanup, "fetch_account_agency_fallbacks", return_value={}
  1590. ), patch.object(
  1591. cleanup,
  1592. "fetch_active_creative_inventory",
  1593. return_value=[{
  1594. "account_id": 1,
  1595. "adgroup_id": 2,
  1596. "creative_id": 3,
  1597. "create_time": created_at,
  1598. }],
  1599. ), patch.object(
  1600. cleanup,
  1601. "prefetch_account_access_tokens",
  1602. return_value={1: "access-token"},
  1603. ), patch.object(
  1604. cleanup, "upsert_cleanup_candidate"
  1605. ), patch.object(
  1606. cleanup, "load_retryable_cleanup_items", return_value=[retry_item]
  1607. ), patch.object(
  1608. cleanup, "claim_cleanup_item", return_value=True
  1609. ), patch.object(
  1610. cleanup, "update_cleanup_item", return_value=True
  1611. ), patch.object(
  1612. cleanup, "load_unnotified_deleted_items", return_value=[]
  1613. ), patch.object(
  1614. cleanup, "advisory_lock", side_effect=acquired_lock
  1615. ):
  1616. summary = cleanup.run_rejected_creative_cleanup(
  1617. output_dir=Path(directory),
  1618. now=current,
  1619. tencent=tencent,
  1620. odps=Mock(),
  1621. review_fetcher=Mock(return_value=[]),
  1622. )
  1623. tencent.delete_dynamic_creative.assert_called_once_with(1, 3)
  1624. self.assertEqual(summary["deleted"], 1)
  1625. self.assertEqual(summary["deferred"], 0)
  1626. def test_zero_spend_ad_apply_runs_after_reconfirmation(self):
  1627. import pandas as pd
  1628. from tools import creative_rejection_cleanup as cleanup
  1629. @contextmanager
  1630. def acquired_lock(_name):
  1631. yield True
  1632. current = datetime(2026, 8, 19, 11, tzinfo=ZoneInfo("Asia/Shanghai"))
  1633. ad = {
  1634. "adgroup_id": 2,
  1635. "adgroup_name": "广告二",
  1636. "configured_status": "AD_STATUS_NORMAL",
  1637. "created_time": "2026-08-10 00:00:00",
  1638. }
  1639. tencent = Mock()
  1640. tencent.get_dynamic_creatives.return_value = [{
  1641. "dynamic_creative_id": 3,
  1642. "adgroup_id": 2,
  1643. "configured_status": "AD_STATUS_NORMAL",
  1644. "creative_set_approval_status": (
  1645. "CREATIVE_SET_APPROVAL_STATUS_PENDING"
  1646. ),
  1647. "system_status": "DYNAMIC_CREATIVE_STATUS_NORMAL",
  1648. }]
  1649. tencent.get_ads.return_value = [ad]
  1650. tencent.get_ad_metrics.return_value = {
  1651. 2: {"impressions": 0, "cost_fen": 0}
  1652. }
  1653. tencent.get_ad.return_value = ad
  1654. tencent.delete_ad.return_value = {
  1655. "adgroup_id": 2,
  1656. "configured_status": "AD_STATUS_DELETED",
  1657. }
  1658. retry_item = {
  1659. "id": 9,
  1660. "account_id": 1,
  1661. "account_name": "账户一",
  1662. "agency_name": "",
  1663. "adgroup_id": 2,
  1664. "adgroup_name": "广告二",
  1665. "dynamic_creative_id": -2,
  1666. "dynamic_creative_name": "",
  1667. "cleanup_action": cleanup.DELETE_AD,
  1668. "cleanup_rule_type": cleanup.PERFORMANCE_AD_ZERO_SPEND_RULE,
  1669. "cleanup_status": "DISCOVERED",
  1670. }
  1671. with TemporaryDirectory() as directory, patch.dict(
  1672. os.environ,
  1673. {
  1674. "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "0",
  1675. "DAILY_UNDERPERFORMING_CREATIVE_CLEANUP_ENABLED": "1",
  1676. "DAILY_UNDERPERFORMING_CREATIVE_APPLY_ENABLED": "0",
  1677. "DAILY_UNDERPERFORMING_AD_CLEANUP_ENABLED": "1",
  1678. "DAILY_UNDERPERFORMING_AD_APPLY_ENABLED": "1",
  1679. "ROI_AGENCY_WEBHOOK_ENABLED": "0",
  1680. "FEISHU_AD_PROJECT_CHAT_ID": "chat-internal",
  1681. },
  1682. clear=False,
  1683. ), patch.object(cleanup, "initialize_schema"), patch.object(
  1684. cleanup, "resolve_end_date", return_value="20260818"
  1685. ), patch.object(
  1686. cleanup, "fetch_daily_data", return_value=pd.DataFrame()
  1687. ), patch.object(
  1688. cleanup, "fetch_recent_spend_accounts", return_value=[]
  1689. ), patch.object(
  1690. cleanup, "fetch_account_agency_fallbacks", return_value={}
  1691. ), patch.object(
  1692. cleanup,
  1693. "fetch_active_creative_inventory",
  1694. return_value=[{
  1695. "account_id": 1,
  1696. "adgroup_id": 2,
  1697. "creative_id": 3,
  1698. "create_time": datetime(
  1699. 2026,
  1700. 8,
  1701. 10,
  1702. tzinfo=ZoneInfo("Asia/Shanghai"),
  1703. ),
  1704. }],
  1705. ), patch.object(
  1706. cleanup, "prefetch_account_access_tokens", return_value={1: "token"}
  1707. ), patch.object(
  1708. cleanup, "upsert_cleanup_candidate"
  1709. ), patch.object(
  1710. cleanup, "load_retryable_cleanup_items", return_value=[retry_item]
  1711. ), patch.object(
  1712. cleanup, "claim_cleanup_item", return_value=True
  1713. ), patch.object(
  1714. cleanup, "update_cleanup_item", return_value=True
  1715. ), patch.object(
  1716. cleanup, "load_unnotified_deleted_items", return_value=[]
  1717. ), patch.object(
  1718. cleanup, "advisory_lock", side_effect=acquired_lock
  1719. ):
  1720. summary = cleanup.run_rejected_creative_cleanup(
  1721. output_dir=Path(directory),
  1722. now=current,
  1723. tencent=tencent,
  1724. odps=Mock(),
  1725. review_fetcher=Mock(return_value=[]),
  1726. )
  1727. tencent.delete_ad.assert_called_once_with(1, 2)
  1728. self.assertEqual(summary["deleted"], 1)
  1729. self.assertEqual(summary["ad_discovered"], 1)
  1730. def test_write_lock_recheck_blocks_deletes_when_today_spend_appears(self):
  1731. import pandas as pd
  1732. from tools import creative_rejection_cleanup as cleanup
  1733. @contextmanager
  1734. def acquired_lock(_name):
  1735. yield True
  1736. current = datetime(2026, 8, 19, 11, tzinfo=ZoneInfo("Asia/Shanghai"))
  1737. created_at = datetime(
  1738. 2026,
  1739. 8,
  1740. 13,
  1741. 10,
  1742. tzinfo=ZoneInfo("Asia/Shanghai"),
  1743. )
  1744. creative = {
  1745. "dynamic_creative_id": 3,
  1746. "dynamic_creative_name": "创意三",
  1747. "adgroup_id": 2,
  1748. "configured_status": "AD_STATUS_NORMAL",
  1749. "creative_set_approval_status": cleanup.CREATIVE_NORMAL_STATUS,
  1750. "system_status": "DYNAMIC_CREATIVE_STATUS_NORMAL",
  1751. }
  1752. ad = {
  1753. "adgroup_id": 2,
  1754. "adgroup_name": "广告二",
  1755. "configured_status": "AD_STATUS_NORMAL",
  1756. "created_time": "2026-08-10 00:00:00",
  1757. }
  1758. tencent = Mock()
  1759. tencent.get_dynamic_creatives.return_value = [creative]
  1760. tencent.get_ads.return_value = [ad]
  1761. tencent.get_dynamic_creative.return_value = creative
  1762. tencent.get_ad.return_value = ad
  1763. tencent.get_dynamic_creative_metrics.side_effect = [
  1764. {3: {"impressions": 99, "cost_fen": 0}},
  1765. {3: {"impressions": 0, "cost_fen": 0}},
  1766. {3: {"impressions": 99, "cost_fen": 0}},
  1767. {3: {"impressions": 1, "cost_fen": 0}},
  1768. ]
  1769. tencent.get_ad_metrics.side_effect = [
  1770. {2: {"impressions": 0, "cost_fen": 0}},
  1771. {2: {"impressions": 0, "cost_fen": 0}},
  1772. {2: {"impressions": 0, "cost_fen": 0}},
  1773. {2: {"impressions": 0, "cost_fen": 1}},
  1774. ]
  1775. retry_items = [
  1776. {
  1777. "id": 7,
  1778. "account_id": 1,
  1779. "agency_name": "",
  1780. "adgroup_id": 2,
  1781. "dynamic_creative_id": 3,
  1782. "cleanup_action": cleanup.DELETE_CREATIVE,
  1783. "cleanup_rule_type": cleanup.PERFORMANCE_NEW_RULE,
  1784. "cleanup_status": "DISCOVERED",
  1785. "creative_created_at": created_at,
  1786. },
  1787. {
  1788. "id": 8,
  1789. "account_id": 1,
  1790. "agency_name": "",
  1791. "adgroup_id": 2,
  1792. "dynamic_creative_id": -2,
  1793. "cleanup_action": cleanup.DELETE_AD,
  1794. "cleanup_rule_type": cleanup.PERFORMANCE_AD_ZERO_SPEND_RULE,
  1795. "cleanup_status": "DISCOVERED",
  1796. },
  1797. ]
  1798. with TemporaryDirectory() as directory, patch.dict(
  1799. os.environ,
  1800. {
  1801. "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "0",
  1802. "DAILY_UNDERPERFORMING_CREATIVE_CLEANUP_ENABLED": "1",
  1803. "DAILY_UNDERPERFORMING_CREATIVE_APPLY_ENABLED": "1",
  1804. "DAILY_UNDERPERFORMING_AD_CLEANUP_ENABLED": "1",
  1805. "DAILY_UNDERPERFORMING_AD_APPLY_ENABLED": "1",
  1806. "ROI_AGENCY_WEBHOOK_ENABLED": "0",
  1807. "FEISHU_AD_PROJECT_CHAT_ID": "chat-internal",
  1808. },
  1809. clear=False,
  1810. ), patch.object(cleanup, "initialize_schema"), patch.object(
  1811. cleanup, "resolve_end_date", return_value="20260818"
  1812. ), patch.object(
  1813. cleanup, "fetch_daily_data", return_value=pd.DataFrame()
  1814. ), patch.object(
  1815. cleanup, "fetch_recent_spend_accounts", return_value=[]
  1816. ), patch.object(
  1817. cleanup, "fetch_account_agency_fallbacks", return_value={}
  1818. ), patch.object(
  1819. cleanup,
  1820. "fetch_active_creative_inventory",
  1821. return_value=[{
  1822. "account_id": 1,
  1823. "adgroup_id": 2,
  1824. "creative_id": 3,
  1825. "create_time": created_at,
  1826. }],
  1827. ), patch.object(
  1828. cleanup, "prefetch_account_access_tokens", return_value={1: "token"}
  1829. ), patch.object(
  1830. cleanup, "upsert_cleanup_candidate"
  1831. ), patch.object(
  1832. cleanup, "load_retryable_cleanup_items", return_value=retry_items
  1833. ), patch.object(
  1834. cleanup, "claim_cleanup_item", return_value=True
  1835. ), patch.object(
  1836. cleanup, "update_cleanup_item", return_value=True
  1837. ), patch.object(
  1838. cleanup, "load_unnotified_deleted_items", return_value=[]
  1839. ), patch.object(
  1840. cleanup, "advisory_lock", side_effect=acquired_lock
  1841. ):
  1842. summary = cleanup.run_rejected_creative_cleanup(
  1843. output_dir=Path(directory),
  1844. now=current,
  1845. tencent=tencent,
  1846. odps=Mock(),
  1847. review_fetcher=Mock(return_value=[]),
  1848. )
  1849. tencent.delete_dynamic_creative.assert_not_called()
  1850. tencent.delete_ad.assert_not_called()
  1851. self.assertEqual(summary["deleted"], 0)
  1852. self.assertEqual(tencent.get_dynamic_creative_metrics.call_count, 4)
  1853. self.assertEqual(tencent.get_ad_metrics.call_count, 4)
  1854. def test_performance_notification_sends_internal_message_without_webhook(self):
  1855. import pandas as pd
  1856. from tools import creative_rejection_cleanup as cleanup
  1857. @contextmanager
  1858. def acquired_lock(_name):
  1859. yield True
  1860. pending = {
  1861. "id": 7,
  1862. "check_date": date(2026, 8, 19),
  1863. "account_id": 1,
  1864. "adgroup_id": 2,
  1865. "dynamic_creative_id": 3,
  1866. "agency_name": "代理一",
  1867. "cleanup_rule_type": cleanup.PERFORMANCE_NEW_RULE,
  1868. "cleanup_status": "DISCOVERED",
  1869. "operator_notified_at": None,
  1870. "agency_notified_at": None,
  1871. }
  1872. performance_report = {
  1873. "run_id": "performance_20260819_test",
  1874. "report": "unused.xlsx",
  1875. "title": "长期未起量创意清理汇总通知",
  1876. "notification_kind": "performance_internal",
  1877. "creative_rows": 1,
  1878. }
  1879. with TemporaryDirectory() as directory, patch.dict(
  1880. os.environ,
  1881. {
  1882. "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "0",
  1883. "DAILY_UNDERPERFORMING_CREATIVE_CLEANUP_ENABLED": "1",
  1884. "DAILY_UNDERPERFORMING_CREATIVE_APPLY_ENABLED": "0",
  1885. "ROI_AGENCY_WEBHOOK_ENABLED": "0",
  1886. "FEISHU_AD_PROJECT_CHAT_ID": "chat-internal",
  1887. },
  1888. clear=False,
  1889. ), patch.object(cleanup, "initialize_schema"), patch.object(
  1890. cleanup, "resolve_end_date", return_value="20260818"
  1891. ), patch.object(
  1892. cleanup, "fetch_daily_data", return_value=pd.DataFrame()
  1893. ), patch.object(
  1894. cleanup, "fetch_recent_spend_accounts", return_value=[]
  1895. ), patch.object(
  1896. cleanup, "fetch_active_creative_inventory", return_value=[]
  1897. ), patch.object(
  1898. cleanup, "prefetch_account_access_tokens", return_value={}
  1899. ), patch.object(
  1900. cleanup, "load_retryable_cleanup_items", return_value=[]
  1901. ), patch.object(
  1902. cleanup,
  1903. "load_unnotified_deleted_items",
  1904. side_effect=[[pending], [pending]],
  1905. ), patch.object(
  1906. cleanup, "advisory_lock", side_effect=acquired_lock
  1907. ), patch.object(
  1908. cleanup,
  1909. "write_performance_operator_summary",
  1910. return_value=performance_report,
  1911. ), patch.object(
  1912. cleanup,
  1913. "publish_cleanup_operator_summary",
  1914. return_value={"route": "长期未起量清理汇总", "status": "SENT"},
  1915. ) as publish_internal, patch.object(
  1916. cleanup, "publish_agency_reports"
  1917. ) as publish_agency, patch.object(
  1918. cleanup, "mark_cleanup_items_operator_notified"
  1919. ) as mark_internal:
  1920. summary = cleanup.run_rejected_creative_cleanup(
  1921. output_dir=Path(directory),
  1922. now=datetime(
  1923. 2026,
  1924. 8,
  1925. 19,
  1926. 11,
  1927. tzinfo=ZoneInfo("Asia/Shanghai"),
  1928. ),
  1929. tencent=Mock(),
  1930. odps=Mock(),
  1931. publisher=Mock(),
  1932. )
  1933. publish_agency.assert_not_called()
  1934. publish_internal.assert_called_once()
  1935. self.assertEqual(
  1936. publish_internal.call_args.kwargs["chat_id"],
  1937. "chat-internal",
  1938. )
  1939. mark_internal.assert_called_once_with(
  1940. [7],
  1941. datetime(2026, 8, 19, 11, tzinfo=ZoneInfo("Asia/Shanghai")),
  1942. )
  1943. def test_force_preview_resends_already_notified_performance_rows(self):
  1944. from tools import creative_rejection_cleanup as cleanup
  1945. @contextmanager
  1946. def acquired_lock(_name):
  1947. yield True
  1948. current = datetime(
  1949. 2026, 8, 20, 16, 30, tzinfo=ZoneInfo("Asia/Shanghai")
  1950. )
  1951. pending = {
  1952. "id": 9,
  1953. "check_date": current.date(),
  1954. "account_id": 1,
  1955. "adgroup_id": 2,
  1956. "dynamic_creative_id": 3,
  1957. "cleanup_rule_type": cleanup.PERFORMANCE_NEW_RULE,
  1958. "cleanup_status": "DISCOVERED",
  1959. "operator_notified_at": datetime(2026, 8, 20, 11),
  1960. "agency_notified_at": datetime(2026, 8, 20, 11),
  1961. }
  1962. report = {
  1963. "run_id": "performance_2026-08-20_digest",
  1964. "report": "unused.xlsx",
  1965. "title": "长期未起量创意及广告清理汇总通知",
  1966. "notification_kind": "performance_internal",
  1967. "creative_rows": 1,
  1968. "ad_rows": 0,
  1969. }
  1970. with TemporaryDirectory() as directory, patch.dict(
  1971. os.environ,
  1972. {"FEISHU_AD_PROJECT_CHAT_ID": "chat-internal"},
  1973. clear=False,
  1974. ), patch.object(cleanup, "initialize_schema"), patch.object(
  1975. cleanup, "fetch_active_creative_inventory", return_value=[]
  1976. ), patch.object(
  1977. cleanup, "fetch_tencent_account_metadata", return_value={}
  1978. ), patch.object(
  1979. cleanup, "prefetch_account_access_tokens", return_value={}
  1980. ), patch.object(
  1981. cleanup, "load_retryable_cleanup_items", return_value=[]
  1982. ), patch.object(
  1983. cleanup,
  1984. "load_unnotified_deleted_items",
  1985. side_effect=[[pending], [pending]],
  1986. ) as load_notifications, patch.object(
  1987. cleanup,
  1988. "filter_preview_notification_rows",
  1989. side_effect=lambda rows, **_kwargs: [
  1990. {**row, "operator_notified_at": None} for row in rows
  1991. ],
  1992. ), patch.object(
  1993. cleanup, "advisory_lock", side_effect=acquired_lock
  1994. ), patch.object(
  1995. cleanup,
  1996. "write_performance_operator_summary",
  1997. return_value=report,
  1998. ), patch.object(
  1999. cleanup,
  2000. "publish_cleanup_operator_summary",
  2001. return_value={"route": "长期未起量清理汇总", "status": "SENT"},
  2002. ) as publish, patch.object(
  2003. cleanup, "mark_cleanup_items_operator_notified"
  2004. ):
  2005. summary = cleanup.run_rejected_creative_cleanup(
  2006. output_dir=Path(directory),
  2007. now=current,
  2008. tencent=Mock(),
  2009. odps=Mock(),
  2010. publisher=Mock(),
  2011. underperformance_preview_only=True,
  2012. force_notification=True,
  2013. )
  2014. self.assertTrue(summary["force_notification"])
  2015. self.assertTrue(
  2016. publish.call_args.kwargs["run_id"].startswith(
  2017. "performance_2026-08-20_digest_force_"
  2018. )
  2019. )
  2020. self.assertIn("手动重发", publish.call_args.kwargs["report"]["title"])
  2021. for call in load_notifications.call_args_list:
  2022. self.assertTrue(call.kwargs["include_notified"])
  2023. self.assertEqual(summary["notification_errors"], [])
  2024. if __name__ == "__main__":
  2025. unittest.main()