reporting.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431
  1. """调控结果 Excel 报告生成。"""
  2. from __future__ import annotations
  3. from pathlib import Path
  4. from typing import Dict, Mapping, Sequence
  5. import numpy as np
  6. import pandas as pd
  7. from openpyxl import Workbook
  8. from openpyxl.worksheet.datavalidation import DataValidation
  9. from openpyxl.formatting.rule import ColorScaleRule
  10. from openpyxl.styles import Alignment, Font, PatternFill
  11. from openpyxl.utils import get_column_letter
  12. from .fission_multiplier import (
  13. DISPLAY_MULTIPLIER_COLUMN,
  14. DISPLAY_TOTAL_TO_FIRST_COLUMN,
  15. )
  16. from .metrics import ENTITY_GZH, ENTITY_QIWEI, ENTITY_SELF
  17. HEADER_FILL = PatternFill("solid", fgColor="1F4E78")
  18. HEADER_FONT = Font(color="FFFFFF", bold=True)
  19. STOP_FILL = PatternFill("solid", fgColor="F4CCCC")
  20. UP_FILL = PatternFill("solid", fgColor="D9EAD3")
  21. ADJUST_FILL = PatternFill("solid", fgColor="FFF2CC")
  22. APPROVAL_FILL = PatternFill("solid", fgColor="FFD966")
  23. APPROVAL_HEADER_FILL = PatternFill("solid", fgColor="BF9000")
  24. REPORT_VERSION = "roi_report_v9"
  25. REPORT_RUN_SUFFIX = "r9"
  26. T0_FISSION_MULTIPLIER_COLUMN = "裂变系数-总裂变UV/T0裂变UV"
  27. TOTAL_FISSION_TO_FIRST_UV_COLUMN = DISPLAY_TOTAL_TO_FIRST_COLUMN
  28. FINAL_ROI_COLUMN = "最终效率ROI"
  29. BASE_COLUMNS: Dict[str, Sequence[str]] = {
  30. "小程序投流": (
  31. "渠道",
  32. "代理名称",
  33. "账号id",
  34. "广告id",
  35. "广告名称",
  36. "包名",
  37. "广告优化目标",
  38. "创意id",
  39. "广告age",
  40. "日均首层UV",
  41. "首层效率收入",
  42. "T0裂变效率收入",
  43. "总预估效率收入",
  44. "日均成本",
  45. T0_FISSION_MULTIPLIER_COLUMN,
  46. TOTAL_FISSION_TO_FIRST_UV_COLUMN,
  47. FINAL_ROI_COLUMN,
  48. "动作",
  49. "关停线",
  50. "审批选择",
  51. "执行状态",
  52. "执行结果",
  53. ),
  54. "公众号即转": (
  55. "渠道",
  56. "合作方名",
  57. "公众号名",
  58. "日均首层UV",
  59. "首层效率收入",
  60. "T0裂变效率收入",
  61. "总预估效率收入",
  62. "日均成本",
  63. T0_FISSION_MULTIPLIER_COLUMN,
  64. TOTAL_FISSION_TO_FIRST_UV_COLUMN,
  65. FINAL_ROI_COLUMN,
  66. "动作",
  67. "关停线",
  68. ),
  69. "企微群合作": (
  70. "渠道",
  71. "合作方名",
  72. "日均首层UV",
  73. "首层效率收入",
  74. "T0裂变效率收入",
  75. "总预估效率收入",
  76. "日均成本",
  77. T0_FISSION_MULTIPLIER_COLUMN,
  78. TOTAL_FISSION_TO_FIRST_UV_COLUMN,
  79. "传播裂变参数状态",
  80. "调控参与状态",
  81. FINAL_ROI_COLUMN,
  82. "动作",
  83. "关停线",
  84. ),
  85. }
  86. ENTITY_TO_SHEET = {
  87. ENTITY_SELF: "小程序投流",
  88. ENTITY_GZH: "公众号即转",
  89. ENTITY_QIWEI: "企微群合作",
  90. }
  91. FREEZE_PANES = {
  92. "小程序投流": "F2",
  93. "公众号即转": "D2",
  94. "企微群合作": "C2",
  95. }
  96. def _sheet_frame(candidates: pd.DataFrame, sheet_name: str) -> pd.DataFrame:
  97. entity_type = next(
  98. key for key, value in ENTITY_TO_SHEET.items() if value == sheet_name
  99. )
  100. subset = candidates[candidates["entity_type"].eq(entity_type)].copy()
  101. subset = subset.rename(columns={"channel": "渠道"})
  102. coverage_source = (
  103. subset["覆盖天数"]
  104. if "覆盖天数" in subset
  105. else pd.Series(3, index=subset.index)
  106. )
  107. coverage_days = pd.to_numeric(
  108. coverage_source, errors="coerce"
  109. ).replace(0, np.nan)
  110. subset["日均首层UV"] = pd.to_numeric(
  111. subset.get("日均首层UV"), errors="coerce"
  112. ).round().astype("Int64")
  113. subset["首层效率收入"] = (
  114. pd.to_numeric(subset.get("效率收入"), errors="coerce")
  115. / coverage_days
  116. )
  117. subset["T0裂变效率收入"] = (
  118. pd.to_numeric(subset.get("T0实际裂变收入"), errors="coerce")
  119. / coverage_days
  120. )
  121. subset["总预估效率收入"] = (
  122. pd.to_numeric(
  123. subset.get("预测全链路效率收入"), errors="coerce"
  124. )
  125. / coverage_days
  126. )
  127. subset["日均成本"] = (
  128. pd.to_numeric(subset.get("成本"), errors="coerce") / coverage_days
  129. )
  130. subset[T0_FISSION_MULTIPLIER_COLUMN] = pd.to_numeric(
  131. subset.get(DISPLAY_MULTIPLIER_COLUMN), errors="coerce"
  132. )
  133. subset[TOTAL_FISSION_TO_FIRST_UV_COLUMN] = pd.to_numeric(
  134. subset.get(DISPLAY_TOTAL_TO_FIRST_COLUMN), errors="coerce"
  135. )
  136. subset[FINAL_ROI_COLUMN] = subset["ROI"]
  137. subset["关停线"] = subset["t_stop"]
  138. subset["扩量线"] = subset["t_up"]
  139. visible_columns = list(BASE_COLUMNS[sheet_name])
  140. for column in visible_columns:
  141. if column not in subset:
  142. subset[column] = ""
  143. hidden_columns = [
  144. column for column in subset.columns if column not in visible_columns
  145. ]
  146. ordered = visible_columns + hidden_columns
  147. subset = subset[ordered]
  148. if not subset.empty:
  149. subset = subset.sort_values(FINAL_ROI_COLUMN, ascending=True)
  150. return subset
  151. def _write_dataframe(ws, frame: pd.DataFrame) -> None:
  152. ws.append(list(frame.columns))
  153. for values in frame.itertuples(index=False, name=None):
  154. ws.append(
  155. [
  156. None if isinstance(value, float) and np.isnan(value) else value
  157. for value in values
  158. ]
  159. )
  160. def _format_sheet(ws, sheet_name: str) -> None:
  161. max_column = max(ws.max_column, 1)
  162. max_row = max(ws.max_row, 1)
  163. ws.freeze_panes = FREEZE_PANES[sheet_name]
  164. ws.auto_filter.ref = f"A1:{get_column_letter(max_column)}{max_row}"
  165. ws.row_dimensions[1].height = 28
  166. headers = {}
  167. for cell in ws[1]:
  168. cell.fill = HEADER_FILL
  169. cell.font = HEADER_FONT
  170. cell.alignment = Alignment(horizontal="center", vertical="center")
  171. headers[cell.value] = cell.column
  172. approval_column = headers.get("审批选择")
  173. if approval_column:
  174. approval_header = ws.cell(1, approval_column)
  175. approval_header.fill = APPROVAL_HEADER_FILL
  176. approval_header.font = HEADER_FONT
  177. validation = DataValidation(
  178. type="list",
  179. formula1='"批准,拒绝"',
  180. allow_blank=True,
  181. )
  182. validation.error = "请选择批准或拒绝"
  183. validation.errorTitle = "审批值无效"
  184. ws.add_data_validation(validation)
  185. for row in range(2, max_row + 1):
  186. cell = ws.cell(row, approval_column)
  187. if cell.value != "不可执行":
  188. cell.fill = APPROVAL_FILL
  189. validation.add(cell)
  190. for column_index in range(1, max_column + 1):
  191. header = ws.cell(1, column_index).value or ""
  192. values = [str(ws.cell(row, column_index).value or "") for row in range(1, max_row + 1)]
  193. width = min(max(max(map(len, values)), len(str(header))) + 2, 42)
  194. ws.column_dimensions[get_column_letter(column_index)].width = max(width, 11)
  195. if (
  196. "ROI" in str(header)
  197. or "裂变率" in str(header)
  198. or "百分位" in str(header)
  199. or str(header).startswith("裂变系数-")
  200. ):
  201. for row in range(2, max_row + 1):
  202. ws.cell(row, column_index).number_format = "0.000"
  203. elif "成本" in str(header) or header in {
  204. "首层效率收入",
  205. "T0裂变效率收入",
  206. "总预估效率收入",
  207. "效率收入",
  208. "实际全链路效率收入",
  209. "裂变效率收入",
  210. "T0实际裂变收入",
  211. "预测T1-T15裂变收入",
  212. "预测T0-T15裂变收入",
  213. "预测全链路效率收入",
  214. }:
  215. for row in range(2, max_row + 1):
  216. ws.cell(row, column_index).number_format = "#,##0.00"
  217. elif "UV" in str(header) or "裂变数" in str(header):
  218. for row in range(2, max_row + 1):
  219. ws.cell(row, column_index).number_format = "#,##0"
  220. if "ROI" in str(header) and max_row >= 2:
  221. letter = get_column_letter(column_index)
  222. ws.conditional_formatting.add(
  223. f"{letter}2:{letter}{max_row}",
  224. ColorScaleRule(
  225. start_type="min",
  226. start_color="F8696B",
  227. mid_type="percentile",
  228. mid_value=50,
  229. mid_color="FFEB84",
  230. end_type="max",
  231. end_color="63BE7B",
  232. ),
  233. )
  234. if column_index > len(BASE_COLUMNS[sheet_name]):
  235. ws.column_dimensions[get_column_letter(column_index)].hidden = True
  236. action_column = headers.get("动作")
  237. if action_column:
  238. for row in range(2, max_row + 1):
  239. action = ws.cell(row, action_column).value
  240. fill = {
  241. "关停": STOP_FILL,
  242. "扩量": UP_FILL,
  243. "调整封面&落地页视频": ADJUST_FILL,
  244. }.get(action)
  245. if fill:
  246. ws.cell(row, action_column).fill = fill
  247. def _write_summary(
  248. workbook: Workbook,
  249. thresholds: pd.DataFrame,
  250. candidates: pd.DataFrame,
  251. expected_dates: Sequence[str],
  252. rule_config: Mapping[str, object] | None,
  253. ) -> None:
  254. ws = workbook.create_sheet("运行摘要")
  255. self_min_uv = (rule_config or {}).get("self_min_avg_uv", 200)
  256. partner_min_uv = (rule_config or {}).get("partner_min_avg_uv", 200)
  257. min_daily_cost = (rule_config or {}).get("min_daily_cost", 100)
  258. rows = [
  259. ("统计窗口", f"{expected_dates[0]} 至 {expected_dates[-1]}"),
  260. ("首层口径", "usersharedepth='0'"),
  261. ("UV口径", "COUNT(DISTINCT mid)"),
  262. (
  263. "ROI口径",
  264. "源表裂变效率收入为T0当天实际值;预测总收入=首层效率收入+T0实际裂变收入+T0实际裂变收入×(传播裂变系数-1)",
  265. ),
  266. (
  267. "企微口径",
  268. "企微按合作方匹配已发布参数;未匹配或样本不足时默认取2.5。"
  269. "企微仅展示,不进入阈值样本池和调控。",
  270. ),
  271. ("阈值口径", "小程序和公众号合格实体进入同一全局池:三日聚合ROI的P20=关停线、P80=扩量线;企微不参与"),
  272. (
  273. "阈值样本条件",
  274. f"连续3天数据完整;小程序日均首层UV>{self_min_uv:g};"
  275. f"公众号日均首层UV>{partner_min_uv:g};连续3天每天成本>"
  276. f"{min_daily_cost:g};三日成本>0且最终效率ROI有效;企微仅展示、不进入样本池",
  277. ),
  278. ("动作优先级", "关停 > 扩量 > 调整封面&落地页视频"),
  279. ("审批方式", "在小程序投流表黄色【审批选择】列逐行选择批准或拒绝;批准即为最终确认并自动执行"),
  280. ]
  281. for label, value in rows:
  282. ws.append([label, value])
  283. if rule_config:
  284. ws.append([])
  285. ws.append(["规则配置", "值"])
  286. for key in (
  287. "self_stop_min_age",
  288. "self_up_min_age",
  289. "self_min_avg_uv",
  290. "partner_min_avg_uv",
  291. "min_daily_cost",
  292. "stop_quantile",
  293. "up_quantile",
  294. "scale_ratio",
  295. "scale_cooldown_days",
  296. "max_base_ratio",
  297. ):
  298. ws.append([key, rule_config.get(key)])
  299. fission_multiplier = rule_config.get("fission_multiplier")
  300. if isinstance(fission_multiplier, Mapping):
  301. ws.append([])
  302. ws.append(["传播裂变参数", "值"])
  303. for key in (
  304. "version",
  305. "cohort_date",
  306. "observation_end_date",
  307. "horizon_days",
  308. "miniapp_exact_available_rows",
  309. "gzh_exact_available_rows",
  310. "miniapp_channel_multiplier",
  311. "gzh_channel_multiplier",
  312. "qiwei_exact_available_rows",
  313. ):
  314. if key in fission_multiplier:
  315. ws.append([key, fission_multiplier[key]])
  316. ws.append([])
  317. display_thresholds = thresholds.rename(
  318. columns={"t_stop": "关停线", "t_up": "扩量线"}
  319. )
  320. threshold_columns = [
  321. "统计窗口",
  322. "关停线",
  323. "扩量线",
  324. "阈值样本数",
  325. "小程序样本数",
  326. "公众号样本数",
  327. "企微参考实体数",
  328. ]
  329. ws.append(threshold_columns)
  330. for values in display_thresholds[threshold_columns].itertuples(
  331. index=False,
  332. name=None,
  333. ):
  334. ws.append(list(values))
  335. ws.append([])
  336. ws.append(["渠道", "建议动作数"])
  337. for entity_type, sheet_name in ENTITY_TO_SHEET.items():
  338. count = int(
  339. (
  340. candidates["entity_type"].eq(entity_type)
  341. & candidates["动作"].ne("")
  342. ).sum()
  343. )
  344. ws.append([sheet_name, count])
  345. ws.column_dimensions["A"].width = 22
  346. ws.column_dimensions["B"].width = 92
  347. for row in ws.iter_rows():
  348. if row[0].value in {"统计窗口", "渠道"}:
  349. for cell in row:
  350. cell.font = Font(bold=True)
  351. ws.freeze_panes = "A2"
  352. def write_workbook(
  353. candidates: pd.DataFrame,
  354. thresholds: pd.DataFrame,
  355. expected_dates: Sequence[str],
  356. output_path: Path,
  357. rule_config: Mapping[str, object] | None = None,
  358. fission_match_summary: pd.DataFrame | None = None,
  359. ) -> Path:
  360. output_path.parent.mkdir(parents=True, exist_ok=True)
  361. workbook = Workbook()
  362. workbook.remove(workbook.active)
  363. for sheet_name in BASE_COLUMNS:
  364. ws = workbook.create_sheet(sheet_name)
  365. frame = _sheet_frame(candidates, sheet_name)
  366. _write_dataframe(ws, frame)
  367. _format_sheet(ws, sheet_name)
  368. if fission_match_summary is not None:
  369. ws = workbook.create_sheet("传播裂变系数匹配")
  370. _write_dataframe(ws, fission_match_summary)
  371. ws.freeze_panes = "A2"
  372. ws.auto_filter.ref = ws.dimensions
  373. for cell in ws[1]:
  374. cell.fill = HEADER_FILL
  375. cell.font = HEADER_FONT
  376. for column_index in range(1, ws.max_column + 1):
  377. header = str(ws.cell(1, column_index).value or "")
  378. values = [
  379. str(ws.cell(row, column_index).value or "")
  380. for row in range(1, ws.max_row + 1)
  381. ]
  382. width = min(max(max(map(len, values)), len(header)) + 2, 42)
  383. ws.column_dimensions[get_column_letter(column_index)].width = max(
  384. width, 11
  385. )
  386. if header == "匹配率":
  387. for row in range(2, ws.max_row + 1):
  388. ws.cell(row, column_index).number_format = "0.00%"
  389. _write_summary(
  390. workbook,
  391. thresholds,
  392. candidates,
  393. expected_dates,
  394. rule_config,
  395. )
  396. workbook.save(output_path)
  397. return output_path