reporting.py 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. """调控结果 Excel 报告生成。"""
  2. from __future__ import annotations
  3. from pathlib import Path
  4. from typing import Dict, Iterable, Mapping, Sequence
  5. import numpy as np
  6. import pandas as pd
  7. from openpyxl import Workbook
  8. from openpyxl.formatting.rule import ColorScaleRule
  9. from openpyxl.styles import Alignment, Font, PatternFill
  10. from openpyxl.utils import get_column_letter
  11. from .metrics import ENTITY_GZH, ENTITY_QIWEI, ENTITY_SELF
  12. HEADER_FILL = PatternFill("solid", fgColor="1F4E78")
  13. HEADER_FONT = Font(color="FFFFFF", bold=True)
  14. STOP_FILL = PatternFill("solid", fgColor="F4CCCC")
  15. UP_FILL = PatternFill("solid", fgColor="D9EAD3")
  16. ADJUST_FILL = PatternFill("solid", fgColor="FFF2CC")
  17. BASE_COLUMNS: Dict[str, Sequence[str]] = {
  18. "小程序投流": (
  19. "渠道",
  20. "代理名称",
  21. "账号id",
  22. "账号名称",
  23. "广告id",
  24. "广告名称",
  25. "包名",
  26. "创意id",
  27. "广告age",
  28. "日均首层UV",
  29. "三日首层UV",
  30. "三日T0裂变数",
  31. "T0裂变率",
  32. "效率收入",
  33. "多层裂变收入",
  34. "全链路效率收入",
  35. "成本",
  36. "三日最小单日成本",
  37. "三日聚合ROI",
  38. "关停线",
  39. "扩量线",
  40. "动作",
  41. "执行模式",
  42. "执行说明",
  43. ),
  44. "公众号即转": (
  45. "渠道",
  46. "合作方名",
  47. "公众号名",
  48. "日均首层UV",
  49. "三日首层UV",
  50. "三日T0裂变数",
  51. "T0裂变率",
  52. "效率收入",
  53. "多层裂变收入",
  54. "全链路效率收入",
  55. "成本",
  56. "三日最小单日成本",
  57. "三日聚合ROI",
  58. "关停线",
  59. "扩量线",
  60. "渠道内ROI排名百分位",
  61. "动作",
  62. "执行模式",
  63. "执行说明",
  64. ),
  65. "企微群合作": (
  66. "渠道",
  67. "合作方名",
  68. "公众号名",
  69. "日均首层UV",
  70. "三日首层UV",
  71. "三日T0裂变数",
  72. "T0裂变率",
  73. "效率收入",
  74. "多层裂变收入",
  75. "全链路效率收入",
  76. "成本",
  77. "三日最小单日成本",
  78. "三日聚合ROI",
  79. "关停线",
  80. "扩量线",
  81. "动作",
  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 _date_detail_columns(columns: Iterable[str]) -> list[str]:
  97. result = [
  98. column
  99. for column in columns
  100. if column.startswith(("首层UV_20", "成本_20", "ROI_20"))
  101. ]
  102. return sorted(result, key=lambda value: (value.rsplit("_", 1)[-1], value.split("_", 1)[0]))
  103. def _sheet_frame(candidates: pd.DataFrame, sheet_name: str) -> pd.DataFrame:
  104. entity_type = next(
  105. key for key, value in ENTITY_TO_SHEET.items() if value == sheet_name
  106. )
  107. subset = candidates[candidates["entity_type"].eq(entity_type)].copy()
  108. subset = subset.rename(columns={"channel": "渠道"})
  109. subset["三日聚合ROI"] = subset["ROI"]
  110. subset["关停线"] = subset["t_stop"]
  111. subset["扩量线"] = subset["t_up"]
  112. date_columns = _date_detail_columns(subset.columns)
  113. ordered = list(BASE_COLUMNS[sheet_name]) + date_columns
  114. for column in ordered:
  115. if column not in subset:
  116. subset[column] = ""
  117. subset = subset[ordered]
  118. if not subset.empty:
  119. subset = subset.sort_values(["动作", "三日聚合ROI"], ascending=[True, False])
  120. return subset
  121. def _write_dataframe(ws, frame: pd.DataFrame) -> None:
  122. ws.append(list(frame.columns))
  123. for values in frame.itertuples(index=False, name=None):
  124. ws.append(
  125. [
  126. None if isinstance(value, float) and np.isnan(value) else value
  127. for value in values
  128. ]
  129. )
  130. def _format_sheet(ws, sheet_name: str) -> None:
  131. max_column = max(ws.max_column, 1)
  132. max_row = max(ws.max_row, 1)
  133. ws.freeze_panes = FREEZE_PANES[sheet_name]
  134. ws.auto_filter.ref = f"A1:{get_column_letter(max_column)}{max_row}"
  135. ws.row_dimensions[1].height = 28
  136. headers = {}
  137. for cell in ws[1]:
  138. cell.fill = HEADER_FILL
  139. cell.font = HEADER_FONT
  140. cell.alignment = Alignment(horizontal="center", vertical="center")
  141. headers[cell.value] = cell.column
  142. for column_index in range(1, max_column + 1):
  143. header = ws.cell(1, column_index).value or ""
  144. values = [str(ws.cell(row, column_index).value or "") for row in range(1, max_row + 1)]
  145. width = min(max(max(map(len, values)), len(str(header))) + 2, 42)
  146. ws.column_dimensions[get_column_letter(column_index)].width = max(width, 11)
  147. if "ROI" in str(header) or "裂变率" in str(header) or "百分位" in str(header):
  148. for row in range(2, max_row + 1):
  149. ws.cell(row, column_index).number_format = "0.000"
  150. elif "成本" in str(header) or header in {
  151. "效率收入",
  152. "多层裂变收入",
  153. "全链路效率收入",
  154. }:
  155. for row in range(2, max_row + 1):
  156. ws.cell(row, column_index).number_format = "#,##0.00"
  157. elif "UV" in str(header) or "裂变数" in str(header):
  158. for row in range(2, max_row + 1):
  159. ws.cell(row, column_index).number_format = "#,##0.00"
  160. if "ROI" in str(header) and max_row >= 2:
  161. letter = get_column_letter(column_index)
  162. ws.conditional_formatting.add(
  163. f"{letter}2:{letter}{max_row}",
  164. ColorScaleRule(
  165. start_type="min",
  166. start_color="F8696B",
  167. mid_type="percentile",
  168. mid_value=50,
  169. mid_color="FFEB84",
  170. end_type="max",
  171. end_color="63BE7B",
  172. ),
  173. )
  174. action_column = headers.get("动作")
  175. if action_column:
  176. for row in range(2, max_row + 1):
  177. action = ws.cell(row, action_column).value
  178. fill = {
  179. "关停": STOP_FILL,
  180. "扩量": UP_FILL,
  181. "调整封面&落地页视频": ADJUST_FILL,
  182. }.get(action)
  183. if fill:
  184. ws.cell(row, action_column).fill = fill
  185. def _write_summary(
  186. workbook: Workbook,
  187. thresholds: pd.DataFrame,
  188. candidates: pd.DataFrame,
  189. expected_dates: Sequence[str],
  190. qiwei_fission_is_zero: bool,
  191. rule_config: Mapping[str, object] | None,
  192. ) -> None:
  193. ws = workbook.active
  194. ws.title = "运行摘要"
  195. rows = [
  196. ("统计窗口", f"{expected_dates[0]} 至 {expected_dates[-1]}"),
  197. ("首层口径", "usersharedepth='0'"),
  198. ("UV口径", "COUNT(DISTINCT mid)"),
  199. ("ROI口径", "三日聚合:(三日效率收入 + 三日多层t0裂变收入) / 三日成本"),
  200. ("阈值口径", "三类合格实体混入同一全局池:三日聚合ROI的P10=关停线、P80=扩量线"),
  201. (
  202. "合格池口径",
  203. "完整覆盖3天;UV、单日成本、广告age与分位门槛按本批次规则配置执行",
  204. ),
  205. ("动作优先级", "关停 > 扩量 > 调整封面&落地页视频"),
  206. ("审批方式", "飞书消息整批确认或拒绝;表格仅用于查看,不做逐行审批"),
  207. ]
  208. for label, value in rows:
  209. ws.append([label, value])
  210. if rule_config:
  211. ws.append([])
  212. ws.append(["规则配置", "值"])
  213. for key in (
  214. "self_stop_min_age",
  215. "self_up_min_age",
  216. "self_min_avg_uv",
  217. "partner_min_avg_uv",
  218. "min_daily_cost",
  219. "stop_quantile",
  220. "up_quantile",
  221. "scale_ratio",
  222. "scale_cooldown_days",
  223. "max_base_ratio",
  224. ):
  225. ws.append([key, rule_config.get(key)])
  226. ws.append([])
  227. display_thresholds = thresholds.rename(
  228. columns={"t_stop": "关停线", "t_up": "扩量线"}
  229. )
  230. threshold_columns = [
  231. "统计窗口",
  232. "关停线",
  233. "扩量线",
  234. "阈值样本数",
  235. "小程序样本数",
  236. "公众号样本数",
  237. "企微样本数",
  238. ]
  239. ws.append(threshold_columns)
  240. for values in display_thresholds[threshold_columns].itertuples(
  241. index=False,
  242. name=None,
  243. ):
  244. ws.append(list(values))
  245. ws.append([])
  246. ws.append(["渠道", "建议动作数"])
  247. for entity_type, sheet_name in ENTITY_TO_SHEET.items():
  248. count = int(candidates["entity_type"].eq(entity_type).sum())
  249. ws.append([sheet_name, count])
  250. if qiwei_fission_is_zero:
  251. ws.append([])
  252. ws.append(
  253. [
  254. "数据质量告警",
  255. "企微群合作在统计窗口内的多层t0裂变收入合计为0;本报告严格按附件公式计算,未使用ARPU估算。",
  256. ]
  257. )
  258. ws.column_dimensions["A"].width = 22
  259. ws.column_dimensions["B"].width = 92
  260. for row in ws.iter_rows():
  261. if row[0].value in {"统计窗口", "渠道"}:
  262. for cell in row:
  263. cell.font = Font(bold=True)
  264. ws.freeze_panes = "A2"
  265. def write_workbook(
  266. candidates: pd.DataFrame,
  267. thresholds: pd.DataFrame,
  268. expected_dates: Sequence[str],
  269. output_path: Path,
  270. qiwei_fission_is_zero: bool,
  271. rule_config: Mapping[str, object] | None = None,
  272. ) -> Path:
  273. output_path.parent.mkdir(parents=True, exist_ok=True)
  274. workbook = Workbook()
  275. _write_summary(
  276. workbook,
  277. thresholds,
  278. candidates,
  279. expected_dates,
  280. qiwei_fission_is_zero,
  281. rule_config,
  282. )
  283. for sheet_name in BASE_COLUMNS:
  284. ws = workbook.create_sheet(sheet_name)
  285. frame = _sheet_frame(candidates, sheet_name)
  286. _write_dataframe(ws, frame)
  287. _format_sheet(ws, sheet_name)
  288. workbook.save(output_path)
  289. return output_path