reporting.py 14 KB

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