test_roi_control_metrics.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687
  1. import tempfile
  2. import unittest
  3. from datetime import datetime
  4. from pathlib import Path
  5. from types import SimpleNamespace
  6. import pandas as pd
  7. from openpyxl import load_workbook
  8. from roi_control.data_source import (
  9. SourceDataNotReadyError,
  10. build_daily_sql,
  11. build_source_readiness_sql,
  12. date_window,
  13. resolve_end_date,
  14. )
  15. from roi_control.fission_multiplier import (
  16. DISPLAY_MULTIPLIER_COLUMN,
  17. load_fission_multiplier_parameters,
  18. )
  19. from roi_control.metrics import (
  20. ENTITY_GZH,
  21. ENTITY_SELF,
  22. ENTITY_SELF_AD,
  23. GZH_CHANNEL,
  24. SELF_CHANNEL,
  25. prepare_daily_metrics,
  26. )
  27. from roi_control.reporting import (
  28. AGENCY_SUMMARY_SHEETS,
  29. DAILY_SHEETS,
  30. SUMMARY_SHEETS,
  31. _daily_frame,
  32. _summary_frame,
  33. _visible_columns,
  34. write_agency_workbooks,
  35. write_workbook,
  36. )
  37. from roi_control.rules import evaluate_rules as _evaluate_rules
  38. from roi_control.service import _annotate_current_creative_status, _run_identity
  39. from tencent_client import ACTIVE_STATUS, SUSPEND_STATUS
  40. DATES = ["20260720", "20260721", "20260722"]
  41. FISSION_PARAMETERS = load_fission_multiplier_parameters()
  42. def evaluate_rules(*args, **kwargs):
  43. kwargs["fission_parameters"] = FISSION_PARAMETERS
  44. return _evaluate_rules(*args, **kwargs)
  45. def row(entity_type, entity_id, dt, roi, *, uv=600, cost=200.0):
  46. common = {
  47. "dt": dt,
  48. "entity_type": entity_type,
  49. "channel": SELF_CHANNEL if entity_type != ENTITY_GZH else GZH_CHANNEL,
  50. "代理名称": "",
  51. "账号id": "",
  52. "账号名称": "",
  53. "广告id": "",
  54. "广告名称": "",
  55. "包名": "",
  56. "广告优化目标": "",
  57. "创意id": "",
  58. "合作方名": "",
  59. "公众号名": "",
  60. "首层UV": uv,
  61. "T0裂变数": uv * 0.2,
  62. "成本": cost,
  63. "效率收入": roi * cost,
  64. "裂变效率收入": 0,
  65. }
  66. if entity_type in (ENTITY_SELF, ENTITY_SELF_AD):
  67. common.update(
  68. {
  69. "代理名称": "代理",
  70. "账号id": "84502354",
  71. "账号名称": "账户",
  72. "广告id": entity_id,
  73. "广告名称": f"广告{entity_id}",
  74. "包名": "泛人群",
  75. "广告优化目标": "关键页面访问次数",
  76. "创意id": f"creative-{entity_id}" if entity_type == ENTITY_SELF else "",
  77. }
  78. )
  79. else:
  80. common.update({"合作方名": "合作方", "公众号名": entity_id})
  81. return common
  82. class RoiThreeDayRulesTest(unittest.TestCase):
  83. @staticmethod
  84. def source_client(latest_dt, *, row_count=100, self_rows=60, gzh_rows=40):
  85. partition = SimpleNamespace(partition_spec={"dt": latest_dt})
  86. table = SimpleNamespace(get_max_partition=lambda: partition)
  87. odps = SimpleNamespace(get_table=lambda _name: table)
  88. return SimpleNamespace(
  89. odps=odps,
  90. execute_sql=lambda _sql: pd.DataFrame(
  91. [
  92. {
  93. "row_count": row_count,
  94. "self_rows": self_rows,
  95. "gzh_rows": gzh_rows,
  96. }
  97. ]
  98. ),
  99. )
  100. def test_source_readiness_requires_exact_t_minus_one(self):
  101. client = self.source_client("20260803")
  102. end_date = resolve_end_date(
  103. client,
  104. now=datetime(2026, 8, 4, 9, 0),
  105. )
  106. self.assertEqual(end_date, "20260803")
  107. sql = build_source_readiness_sql(end_date)
  108. self.assertIn("dt = '20260803'", sql)
  109. self.assertIn(SELF_CHANNEL, sql)
  110. self.assertIn(GZH_CHANNEL, sql)
  111. def test_source_readiness_never_falls_back_to_older_partition(self):
  112. client = self.source_client("20260802")
  113. with self.assertRaisesRegex(SourceDataNotReadyError, "required_dt=20260803"):
  114. resolve_end_date(client, now=datetime(2026, 8, 4, 9, 0))
  115. def test_source_readiness_rejects_missing_report_channel(self):
  116. client = self.source_client("20260803", gzh_rows=0)
  117. with self.assertRaisesRegex(SourceDataNotReadyError, "gzh_rows"):
  118. resolve_end_date(client, now=datetime(2026, 8, 4, 9, 0))
  119. def build_daily(self):
  120. rows = []
  121. for dt in DATES:
  122. for index, roi in enumerate([0.1, 1.0, 3.0, 4.0]):
  123. rows.append(row(ENTITY_SELF, f"creative-ad-{index}", dt, roi))
  124. for index, roi in enumerate([0.5, 2.0, 5.0, 6.0]):
  125. rows.append(row(ENTITY_GZH, f"公众号-{index}", dt, roi, uv=300))
  126. for index, roi in enumerate([0.2, 3.5]):
  127. rows.append(row(ENTITY_SELF_AD, f"ad-{index}", dt, roi))
  128. return pd.DataFrame(rows)
  129. def ages(self):
  130. return pd.DataFrame(
  131. {
  132. "广告id": [f"creative-ad-{i}" for i in range(4)]
  133. + [f"ad-{i}" for i in range(2)],
  134. "广告age": [10] * 6,
  135. }
  136. )
  137. def test_versions_and_three_day_window(self):
  138. self.assertEqual(date_window("20260722"), ("20260720", "20260722"))
  139. run_id, _ = _run_identity("20260722", FISSION_PARAMETERS)
  140. self.assertIn("m8", run_id)
  141. self.assertIn("p12", run_id)
  142. self.assertIn("r36", run_id)
  143. def test_current_creative_status_only_reads_stop_decisions(self):
  144. rows = pd.DataFrame(
  145. [
  146. {"entity_type": ENTITY_SELF, "动作": "关停", "账号id": "1", "创意id": "11"},
  147. {"entity_type": ENTITY_SELF, "动作": "扩量", "账号id": "1", "创意id": "12"},
  148. {"entity_type": ENTITY_SELF, "动作": "关停", "账号id": "1", "创意id": "13"},
  149. {"entity_type": ENTITY_GZH, "动作": "关停", "账号id": "", "创意id": ""},
  150. ]
  151. )
  152. class FakeTencent:
  153. def __init__(self):
  154. self.calls = []
  155. def get_dynamic_creative(self, account_id, creative_id):
  156. self.calls.append((account_id, creative_id))
  157. return {
  158. "configured_status": (
  159. ACTIVE_STATUS if creative_id == 11 else SUSPEND_STATUS
  160. )
  161. }
  162. client = FakeTencent()
  163. result = _annotate_current_creative_status(rows, client)
  164. self.assertEqual(client.calls, [(1, 11), (1, 13)])
  165. self.assertEqual(
  166. result["当前创意状态"].tolist(),
  167. ["正常", "", "已停止", ""],
  168. )
  169. def test_daily_sql_has_direct_ad_grain_and_excludes_qiwei(self):
  170. sql = build_daily_sql(DATES[0], DATES[-1])
  171. self.assertEqual(sql.count("SUM(NVL(t0_fission_uv_root, 0))"), 3)
  172. self.assertIn("'self_ad' AS entity_type", sql)
  173. self.assertNotIn("'qiwei' AS entity_type", sql)
  174. self.assertIn("COUNT(DISTINCT mid) AS 首层UV", sql)
  175. self.assertIn("usersharedepth <= 1", sql)
  176. self.assertNotIn("usersharedepth = '0'", sql)
  177. def test_global_p20_and_creative_only_p80_scale_actions(self):
  178. candidates, thresholds, summary = evaluate_rules(
  179. self.build_daily(), DATES, self.ages()
  180. )
  181. self.assertEqual(len(thresholds), 1)
  182. self.assertAlmostEqual(float(thresholds.iloc[0]["t_stop"]), 0.7)
  183. self.assertAlmostEqual(float(thresholds.iloc[0]["t_up"]), 3.4)
  184. self.assertEqual(int(thresholds.iloc[0]["阈值样本数"]), 8)
  185. self.assertEqual(int(thresholds.iloc[0]["扩量样本数"]), 4)
  186. self.assertEqual(thresholds.iloc[0]["关停线口径"], "合格实体等权P20")
  187. self.assertEqual(thresholds.iloc[0]["扩量线口径"], "合格小程序创意实体等权P80")
  188. scale_rows = candidates[candidates["动作"].eq("扩量")]
  189. self.assertEqual(len(scale_rows), 1)
  190. self.assertEqual(scale_rows.iloc[0]["entity_type"], ENTITY_SELF)
  191. self.assertEqual(scale_rows.iloc[0]["广告id"], "creative-ad-3")
  192. self.assertTrue(scale_rows.iloc[0]["是否位于创意三日ROI前20%"])
  193. self.assertTrue((summary["覆盖天数"] == 3).all())
  194. pool = summary[summary["entity_type"].isin([ENTITY_SELF, ENTITY_GZH])]
  195. self.assertTrue(pool["阈值样本状态"].eq("进入三日统一阈值样本池").all())
  196. def test_creative_p80_scale_requires_ad_age_at_least_three_days(self):
  197. ages = self.ages()
  198. ages.loc[ages["广告id"].eq("creative-ad-3"), "广告age"] = 2
  199. candidates, _, _ = evaluate_rules(self.build_daily(), DATES, ages)
  200. target = candidates[candidates["广告id"].eq("creative-ad-3")].iloc[0]
  201. self.assertEqual(target["动作"], "观察")
  202. self.assertIn("广告age<3天", target["动作原因"])
  203. def test_ad_level_reuses_threshold_without_entering_sample_and_can_stop(self):
  204. _, thresholds, summary = evaluate_rules(self.build_daily(), DATES, self.ages())
  205. ad_rows = summary[summary["entity_type"].eq(ENTITY_SELF_AD)]
  206. self.assertTrue(
  207. ad_rows["阈值样本状态"].eq("广告级三日合格_不进入阈值样本池").all()
  208. )
  209. actions = dict(zip(ad_rows["广告id"], ad_rows["动作"]))
  210. self.assertEqual(actions["ad-0"], "关停")
  211. self.assertEqual(actions["ad-1"], "")
  212. self.assertTrue(ad_rows["t_stop"].eq(float(thresholds.iloc[0]["t_stop"])).all())
  213. self.assertTrue(ad_rows["调控参与状态"].str.contains("审批后可暂停广告").all())
  214. def test_latest_day_uv_over_200_is_appended_with_fixed_three_day_average(self):
  215. daily = self.build_daily()
  216. latest_only = row(ENTITY_SELF, "latest-only", DATES[-1], 1.5, uv=300)
  217. daily = pd.concat([daily, pd.DataFrame([latest_only])], ignore_index=True)
  218. candidates, _, summary = evaluate_rules(daily, DATES, self.ages())
  219. target = summary[summary["广告id"].eq("latest-only")].iloc[0]
  220. self.assertEqual(target["覆盖天数"], 1)
  221. self.assertEqual(target["阈值样本状态"], "单日补充决策_昨日UV>200")
  222. self.assertEqual(target["动作"], "观察")
  223. self.assertEqual(target["日均首层UV"], 100)
  224. self.assertTrue(candidates["广告id"].eq("latest-only").any())
  225. def test_one_day_supplement_requires_ad_age_over_three_days(self):
  226. daily = self.build_daily()
  227. supplemental = [
  228. row(ENTITY_SELF, "one-day-hard", DATES[-1], 0.2, uv=300),
  229. row(ENTITY_SELF, "one-day-p30", DATES[-1], 0.5, uv=600),
  230. row(ENTITY_SELF, "one-day-young", DATES[-1], 0.6, uv=600),
  231. row(ENTITY_SELF, "one-day-mid", DATES[-1], 8.0, uv=600),
  232. row(ENTITY_SELF, "one-day-high", DATES[-1], 9.0, uv=600),
  233. ]
  234. daily = pd.concat([daily, pd.DataFrame(supplemental)], ignore_index=True)
  235. ages = pd.concat(
  236. [
  237. self.ages(),
  238. pd.DataFrame(
  239. {
  240. "广告id": [
  241. "one-day-hard",
  242. "one-day-p30",
  243. "one-day-young",
  244. "one-day-mid",
  245. "one-day-high",
  246. ],
  247. "广告age": [4, 4, 3, 4, 4],
  248. }
  249. ),
  250. ],
  251. ignore_index=True,
  252. )
  253. _, thresholds, summary = evaluate_rules(daily, DATES, ages)
  254. self.assertEqual(int(thresholds.iloc[0]["单日P30样本数"]), 8)
  255. self.assertAlmostEqual(float(thresholds.iloc[0]["t_one_day_stop"]), 0.64)
  256. targets = summary.set_index("广告id")
  257. self.assertEqual(targets.loc["one-day-hard", "动作"], "关停")
  258. self.assertIn("单日硬关停线", targets.loc["one-day-hard", "动作原因"])
  259. self.assertIn("启用单日补充规则", targets.loc["one-day-hard", "动作原因"])
  260. self.assertIn("最新日预测总效率ROI", targets.loc["one-day-hard", "动作原因"])
  261. self.assertIn("三日预测总效率ROI", targets.loc["one-day-hard", "动作原因"])
  262. self.assertIn("不参与本次单日判断", targets.loc["one-day-hard", "动作原因"])
  263. self.assertIn("广告age=4>3天", targets.loc["one-day-hard", "动作原因"])
  264. self.assertEqual(targets.loc["one-day-p30", "动作"], "关停")
  265. self.assertIn("单日实体等权P30", targets.loc["one-day-p30", "动作原因"])
  266. self.assertEqual(targets.loc["one-day-young", "动作"], "观察")
  267. self.assertIn("广告age=3≤3天", targets.loc["one-day-young", "动作原因"])
  268. self.assertEqual(targets.loc["one-day-mid", "动作"], "观察")
  269. self.assertIn("未命中单日关停规则", targets.loc["one-day-mid", "动作原因"])
  270. creative_frame = _summary_frame(summary, ENTITY_SELF)
  271. creative_stops = creative_frame[
  272. creative_frame["建议动作"].eq("关停创意")
  273. ]
  274. self.assertEqual(
  275. creative_stops["广告id"].tolist(),
  276. ["creative-ad-0", "one-day-hard", "one-day-p30"],
  277. )
  278. self.assertTrue(creative_stops["动作"].eq("关停").all())
  279. self.assertTrue(
  280. summary[
  281. summary["entity_type"].isin([ENTITY_SELF, ENTITY_SELF_AD])
  282. & summary["动作"].eq("关停")
  283. ]["广告age"].ge(4).all()
  284. )
  285. def test_daily_prediction_uses_same_day_t0_fission_revenue_once(self):
  286. raw = pd.DataFrame([row(ENTITY_SELF, "formula", DATES[-1], 0)])
  287. raw.loc[0, ["成本", "效率收入", "裂变效率收入"]] = [100, 50, 30]
  288. result = prepare_daily_metrics(raw, FISSION_PARAMETERS).iloc[0]
  289. multiplier = result[DISPLAY_MULTIPLIER_COLUMN]
  290. self.assertEqual(result["T0实际裂变收入"], 30)
  291. self.assertAlmostEqual(result["预测全链路效率收入"], 50 + 30 * multiplier)
  292. self.assertAlmostEqual(result["ROI"], (50 + 30 * multiplier) / 100)
  293. def test_agency_workbooks_filter_and_physically_remove_sensitive_columns(self):
  294. _, _, summary = evaluate_rules(self.build_daily(), DATES, self.ages())
  295. agency_rows = summary.copy()
  296. creative_indexes = agency_rows[
  297. agency_rows["entity_type"].eq(ENTITY_SELF)
  298. ].index.tolist()
  299. ad_indexes = agency_rows[
  300. agency_rows["entity_type"].eq(ENTITY_SELF_AD)
  301. ].index.tolist()
  302. agency_rows.loc[creative_indexes, "代理名称"] = "代理B"
  303. agency_rows.loc[ad_indexes, "代理名称"] = "代理B"
  304. agency_rows.loc[creative_indexes[0], "代理名称"] = "小程序-代投-贝湉"
  305. agency_rows.loc[creative_indexes[1], "代理名称"] = "小程序 -代投-贝湉"
  306. agency_rows.loc[ad_indexes[0], "代理名称"] = "小程序-代投-贝湉"
  307. original = agency_rows.copy(deep=True)
  308. with tempfile.TemporaryDirectory() as directory:
  309. outputs = write_agency_workbooks(
  310. agency_rows,
  311. Path(directory),
  312. "20260803",
  313. )
  314. self.assertEqual(
  315. [row["agency_name"] for row in outputs],
  316. ["代理B", "小程序-代投-贝湉"],
  317. )
  318. pd.testing.assert_frame_equal(agency_rows, original)
  319. bay = next(
  320. row for row in outputs if row["agency_name"] == "小程序-代投-贝湉"
  321. )
  322. self.assertEqual(bay["creative_rows"], 2)
  323. self.assertEqual(bay["ad_rows"], 1)
  324. self.assertEqual(
  325. Path(bay["report"]).name,
  326. "20260803_小程序-代投-贝湉_调控建议.xlsx",
  327. )
  328. filtered = write_agency_workbooks(
  329. agency_rows,
  330. Path(directory) / "filtered",
  331. "20260803",
  332. agency_names={"代理B"},
  333. )
  334. self.assertEqual(
  335. [row["agency_name"] for row in filtered],
  336. ["代理B"],
  337. )
  338. forbidden_fragments = (
  339. "ROI",
  340. "收入",
  341. "关停线",
  342. "扩量线",
  343. "排名",
  344. "是否位于",
  345. "t_stop",
  346. "t_up",
  347. "审批",
  348. "执行",
  349. "幂等键",
  350. )
  351. for output in outputs:
  352. workbook = load_workbook(output["report"], read_only=False)
  353. expected_sheets = {
  354. AGENCY_SUMMARY_SHEETS[ENTITY_SELF],
  355. AGENCY_SUMMARY_SHEETS[ENTITY_SELF_AD],
  356. }
  357. self.assertEqual(set(workbook.sheetnames), expected_sheets)
  358. self.assertEqual(
  359. workbook[AGENCY_SUMMARY_SHEETS[ENTITY_SELF_AD]].sheet_state,
  360. "hidden",
  361. )
  362. for sheet_name in expected_sheets:
  363. sheet = workbook[sheet_name]
  364. headers = [cell.value for cell in sheet[1]]
  365. for removed in (
  366. "包名",
  367. "广告age",
  368. "日均首层UV",
  369. "建议说明",
  370. "裂变系数-总裂变UV/T0裂变UV",
  371. "裂变系数-总裂变UV/首层UV",
  372. "日均T0裂变人数",
  373. "日均T0裂变率",
  374. ):
  375. self.assertNotIn(removed, headers)
  376. self.assertFalse(
  377. any(
  378. fragment in str(header)
  379. for header in headers
  380. for fragment in forbidden_fragments
  381. )
  382. )
  383. self.assertTrue(
  384. all(
  385. not sheet.column_dimensions[cell.column_letter].hidden
  386. for cell in sheet[1]
  387. )
  388. )
  389. creative_headers = [
  390. cell.value
  391. for cell in workbook[AGENCY_SUMMARY_SHEETS[ENTITY_SELF]][1]
  392. ]
  393. self.assertEqual(
  394. creative_headers,
  395. [
  396. "渠道",
  397. "代理名称",
  398. "账号id",
  399. "账号名称",
  400. "广告id",
  401. "广告名称",
  402. "广告优化目标",
  403. "创意id",
  404. "日均成本",
  405. "评分",
  406. "建议动作",
  407. "当前创意状态",
  408. ],
  409. )
  410. creative = workbook[AGENCY_SUMMARY_SHEETS[ENTITY_SELF]]
  411. score_column = creative_headers.index("评分") + 1
  412. self.assertEqual(
  413. creative.cell(2, score_column).number_format,
  414. "0.00",
  415. )
  416. self.assertNotIn("当日效率ROI", creative_headers)
  417. def test_summary_and_daily_report_frames(self):
  418. daily = self.build_daily()
  419. daily = pd.concat(
  420. [
  421. daily,
  422. pd.DataFrame(
  423. [
  424. row(ENTITY_SELF, "latest-only-low", DATES[-1], 0.1, uv=220),
  425. row(ENTITY_SELF, "latest-only-high", DATES[-1], 9.0, uv=280),
  426. ]
  427. ),
  428. ],
  429. ignore_index=True,
  430. )
  431. _, thresholds, summary = evaluate_rules(daily, DATES, self.ages())
  432. frame = _summary_frame(summary, ENTITY_SELF)
  433. detail = _daily_frame(summary, ENTITY_SELF, DATES)
  434. self.assertEqual(frame.iloc[-1]["阈值样本状态"], "单日补充决策_昨日UV>200")
  435. observations = frame[
  436. frame["阈值样本状态"].eq("单日补充决策_昨日UV>200")
  437. ]
  438. self.assertEqual(observations.iloc[0]["广告id"], "latest-only-low")
  439. formal_actions = frame[
  440. ~frame["阈值样本状态"].eq("单日补充决策_昨日UV>200")
  441. ]["建议动作"].tolist()
  442. action_rank = {"关停创意": 0, "扩量": 1, "观察": 2}
  443. self.assertEqual(
  444. [action_rank[action] for action in formal_actions],
  445. sorted(action_rank[action] for action in formal_actions),
  446. )
  447. self.assertIn("日均总预估效率收入", frame.columns)
  448. neutral = frame[frame["动作"].eq("")]
  449. self.assertTrue(neutral["建议动作"].eq("观察").all())
  450. self.assertTrue(
  451. neutral["建议说明"].str.contains("当前无需关停或扩量").all()
  452. )
  453. self.assertIn("日均T0裂变率", frame.columns)
  454. self.assertNotIn("三日加权平均T0裂变率", _visible_columns(SUMMARY_SHEETS[ENTITY_SELF]))
  455. self.assertIn("当日效率ROI", frame.columns)
  456. self.assertIn("预测总效率ROI", frame.columns)
  457. visible = _visible_columns(SUMMARY_SHEETS[ENTITY_SELF])
  458. self.assertNotIn("整体三日ROI排名百分位", visible)
  459. self.assertNotIn("是否位于三日ROI后20%", visible)
  460. self.assertNotIn("最新日首层UV", visible)
  461. self.assertNotIn("覆盖天数", visible)
  462. self.assertNotIn("审批选择", visible)
  463. self.assertEqual(visible[-1], "当前创意状态")
  464. for removed in ("动作", "动作原因", "阈值样本状态", "执行状态", "执行结果"):
  465. self.assertNotIn(removed, visible)
  466. self.assertEqual(
  467. visible[visible.index("当日效率ROI") : visible.index("建议说明") + 1],
  468. [
  469. "当日效率ROI",
  470. "预测总效率ROI",
  471. "关停线(P20)",
  472. "扩量线(P80)",
  473. "建议动作",
  474. "建议说明",
  475. ],
  476. )
  477. self.assertIn("关停线(P20)", frame.columns)
  478. self.assertTrue(frame["扩量线(P80)"].eq(float(thresholds.iloc[0]["t_up"])).all())
  479. gzh_frame = _summary_frame(summary, ENTITY_GZH)
  480. self.assertTrue(gzh_frame["扩量线(P80)"].isna().all())
  481. self.assertIn("是否位于三日ROI后20%", frame.columns)
  482. self.assertNotIn("关停线(P25)", frame.columns)
  483. self.assertNotIn("整体实体等权P25关停线", frame.columns)
  484. self.assertEqual(set(detail["dt"]), set(DATES))
  485. self.assertTrue((detail.groupby("广告id").size() == 3).all())
  486. self.assertEqual(detail.columns[0], "dt")
  487. self.assertTrue(detail["dt"].astype(str).is_monotonic_decreasing)
  488. self.assertIn("当日效率ROI", detail.columns)
  489. self.assertIn("预测总效率ROI", detail.columns)
  490. with tempfile.TemporaryDirectory() as directory:
  491. output = Path(directory) / "roi.xlsx"
  492. write_workbook(
  493. summary,
  494. thresholds,
  495. DATES,
  496. output,
  497. {},
  498. pd.DataFrame(
  499. [
  500. {
  501. "实体类型": "self",
  502. "匹配层级": "miniapp_package_goal_exact",
  503. "实体数": 1,
  504. "渠道实体数": 1,
  505. "匹配率": 1.0,
  506. "参数版本": "test",
  507. }
  508. ]
  509. ),
  510. )
  511. workbook = load_workbook(output, read_only=False)
  512. expected = set(SUMMARY_SHEETS.values()) | set(DAILY_SHEETS.values())
  513. self.assertTrue(expected.issubset(workbook.sheetnames))
  514. self.assertNotIn("企微群合作", workbook.sheetnames)
  515. self.assertIn("传播裂变系数匹配", workbook.sheetnames)
  516. self.assertEqual(
  517. workbook["传播裂变系数匹配"].sheet_state,
  518. "hidden",
  519. )
  520. for sheet_name in expected:
  521. self.assertEqual(workbook[sheet_name].freeze_panes, "H2")
  522. self.assertEqual(len(workbook[sheet_name].conditional_formatting), 2)
  523. self.assertEqual(
  524. workbook[SUMMARY_SHEETS[ENTITY_SELF_AD]].sheet_state, "hidden"
  525. )
  526. self.assertEqual(
  527. workbook[DAILY_SHEETS[ENTITY_SELF_AD]].sheet_state, "hidden"
  528. )
  529. creative_sheet = workbook[SUMMARY_SHEETS[ENTITY_SELF]]
  530. color_rules = [
  531. rule
  532. for rules in creative_sheet.conditional_formatting._cf_rules.values()
  533. for rule in rules
  534. ]
  535. self.assertEqual(len(color_rules), 2)
  536. for rule in color_rules:
  537. self.assertEqual(rule.type, "colorScale")
  538. colors = [color.rgb[-6:] for color in rule.colorScale.color]
  539. self.assertEqual(colors, ["C00000", "FFEB84", "00B050"])
  540. creative_headers = {
  541. cell.value: cell.column for cell in creative_sheet[1]
  542. }
  543. first_scale_row = next(
  544. row_number
  545. for row_number in range(2, creative_sheet.max_row + 1)
  546. if creative_sheet.cell(
  547. row_number, creative_headers["建议动作"]
  548. ).value
  549. == "扩量"
  550. )
  551. self.assertEqual(
  552. creative_sheet.cell(first_scale_row, 1).border.top.style,
  553. "medium",
  554. )
  555. self.assertEqual(
  556. creative_sheet.cell(
  557. first_scale_row, creative_headers["建议动作"]
  558. ).border.top.style,
  559. "medium",
  560. )
  561. first_observe_row = next(
  562. row_number
  563. for row_number in range(first_scale_row + 1, creative_sheet.max_row + 1)
  564. if creative_sheet.cell(
  565. row_number, creative_headers["建议动作"]
  566. ).value
  567. == "观察"
  568. )
  569. self.assertEqual(
  570. creative_sheet.cell(first_observe_row, 1).border.top.style,
  571. "medium",
  572. )
  573. self.assertEqual(
  574. creative_sheet.cell(
  575. first_observe_row, creative_headers["建议动作"]
  576. ).border.top.style,
  577. "medium",
  578. )
  579. self.assertEqual(
  580. creative_sheet.cell(2, creative_headers["日均首层UV"]).number_format,
  581. "0",
  582. )
  583. self.assertEqual(
  584. creative_sheet.cell(
  585. 2, creative_headers["日均T0裂变人数"]
  586. ).number_format,
  587. "0",
  588. )
  589. self.assertEqual(
  590. creative_sheet.cell(2, creative_headers["广告age"]).number_format,
  591. "0",
  592. )
  593. self.assertEqual(
  594. creative_sheet.cell(
  595. 2, creative_headers["当日效率ROI"]
  596. ).number_format,
  597. "0.00",
  598. )
  599. self.assertEqual(
  600. creative_sheet.cell(
  601. 2, creative_headers["预测总效率ROI"]
  602. ).number_format,
  603. "0.00",
  604. )
  605. for header in (
  606. "裂变系数-总裂变UV/T0裂变UV",
  607. "裂变系数-总裂变UV/首层UV",
  608. ):
  609. self.assertEqual(
  610. creative_sheet.cell(
  611. 2, creative_headers[header]
  612. ).number_format,
  613. "0.00",
  614. )
  615. self.assertEqual(
  616. creative_sheet.cell(
  617. 2, creative_headers["扩量线(P80)"]
  618. ).number_format,
  619. "0.00",
  620. )
  621. run_summary = workbook["运行摘要"]
  622. summary_rows = {
  623. run_summary.cell(row_number, 1).value: row_number
  624. for row_number in range(1, run_summary.max_row + 1)
  625. }
  626. self.assertEqual(
  627. run_summary.cell(summary_rows["创意扩量线(P80)"], 2).number_format,
  628. "0.00",
  629. )
  630. self.assertEqual(
  631. run_summary.cell(summary_rows["扩量样本数"], 2).number_format,
  632. "0",
  633. )
  634. self.assertEqual(
  635. run_summary.cell(summary_rows["单日关停线(P30)"], 2).number_format,
  636. "0.00",
  637. )
  638. self.assertEqual(
  639. run_summary.cell(summary_rows["单日P30样本数"], 2).number_format,
  640. "0",
  641. )
  642. ad_sheet = workbook[SUMMARY_SHEETS[ENTITY_SELF_AD]]
  643. ad_headers = [cell.value for cell in ad_sheet[1]]
  644. self.assertIn("审批选择", ad_headers)
  645. approval_column = ad_headers.index("审批选择") + 1
  646. self.assertTrue(
  647. ad_sheet.column_dimensions[
  648. ad_sheet.cell(1, approval_column).column_letter
  649. ].hidden
  650. )
  651. self.assertGreater(len(ad_sheet.data_validations.dataValidation), 0)
  652. if __name__ == "__main__":
  653. unittest.main()