reporting.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720
  1. """Three-day ROI Excel report generation with hidden audit columns."""
  2. from __future__ import annotations
  3. import json
  4. import re
  5. from pathlib import Path
  6. from typing import Mapping, Sequence
  7. import numpy as np
  8. import pandas as pd
  9. from openpyxl import Workbook
  10. from openpyxl.formatting.rule import ColorScaleRule
  11. from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
  12. from openpyxl.utils import get_column_letter
  13. from openpyxl.worksheet.datavalidation import DataValidation
  14. from .fission_multiplier import (
  15. DISPLAY_MULTIPLIER_COLUMN,
  16. DISPLAY_TOTAL_TO_FIRST_COLUMN,
  17. )
  18. from .metrics import ENTITY_GZH, ENTITY_SELF, ENTITY_SELF_AD
  19. HEADER_FILL = PatternFill("solid", fgColor="1F4E78")
  20. HEADER_FONT = Font(color="FFFFFF", bold=True)
  21. OBSERVE_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_v41"
  25. REPORT_RUN_SUFFIX = "r41"
  26. AGENCY_REPORT_VERSION = "roi_agency_advice_v9"
  27. T0_FISSION_MULTIPLIER_COLUMN = "裂变系数-总裂变UV/T0裂变UV"
  28. TOTAL_FISSION_TO_FIRST_UV_COLUMN = DISPLAY_TOTAL_TO_FIRST_COLUMN
  29. FINAL_ROI_COLUMN = "三日加权平均效率ROI"
  30. SUMMARY_SHEETS = {
  31. ENTITY_SELF: "小程序创意级三日汇总",
  32. ENTITY_SELF_AD: "小程序广告级三日汇总",
  33. ENTITY_GZH: "公众号三日汇总",
  34. }
  35. AGENCY_SUMMARY_SHEETS = {
  36. ENTITY_SELF: "小程序创意调控建议",
  37. ENTITY_SELF_AD: SUMMARY_SHEETS[ENTITY_SELF_AD],
  38. }
  39. DAILY_SHEETS = {
  40. ENTITY_SELF: "小程序创意级每日明细",
  41. ENTITY_SELF_AD: "小程序广告级每日明细",
  42. ENTITY_GZH: "公众号每日明细",
  43. }
  44. SHEET_TO_ENTITY = {
  45. **{value: key for key, value in SUMMARY_SHEETS.items()},
  46. **{value: key for key, value in DAILY_SHEETS.items()},
  47. }
  48. ENTITY_DIMENSIONS = {
  49. ENTITY_SELF: (
  50. "渠道",
  51. "代理名称",
  52. "账号id",
  53. "账号名称",
  54. "广告id",
  55. "广告名称",
  56. "包名",
  57. "广告优化目标",
  58. "创意id",
  59. "广告age",
  60. ),
  61. ENTITY_SELF_AD: (
  62. "渠道",
  63. "代理名称",
  64. "账号id",
  65. "账号名称",
  66. "广告id",
  67. "广告名称",
  68. "包名",
  69. "广告优化目标",
  70. "广告age",
  71. ),
  72. ENTITY_GZH: ("渠道", "合作方名", "公众号名"),
  73. }
  74. AGENCY_ENTITY_DIMENSIONS = {
  75. ENTITY_SELF: (
  76. "渠道",
  77. "代理名称",
  78. "账号id",
  79. "账号名称",
  80. "广告id",
  81. "广告名称",
  82. "广告优化目标",
  83. "创意id",
  84. ),
  85. ENTITY_SELF_AD: (
  86. "渠道",
  87. "代理名称",
  88. "账号id",
  89. "账号名称",
  90. "广告id",
  91. "广告名称",
  92. "广告优化目标",
  93. ),
  94. }
  95. SUMMARY_METRICS = (
  96. "日均首层UV",
  97. "日均T0裂变人数",
  98. "日均T0裂变率",
  99. "日均首层效率收入",
  100. "日均T0裂变效率收入",
  101. "日均总预估效率收入",
  102. "日均成本",
  103. T0_FISSION_MULTIPLIER_COLUMN,
  104. TOTAL_FISSION_TO_FIRST_UV_COLUMN,
  105. "三日均值ROI",
  106. "当日效率ROI",
  107. "预测总效率ROI",
  108. "关停线",
  109. "扩量线(P80)",
  110. "建议动作",
  111. "建议说明",
  112. )
  113. DAILY_METRICS = (
  114. "首层UV",
  115. "T0裂变人数",
  116. "T0裂变率",
  117. "首层效率收入",
  118. "T0裂变效率收入",
  119. "预测总效率收入",
  120. "成本",
  121. "当日效率ROI",
  122. "预测总效率ROI",
  123. T0_FISSION_MULTIPLIER_COLUMN,
  124. TOTAL_FISSION_TO_FIRST_UV_COLUMN,
  125. )
  126. AGENCY_SUMMARY_METRICS = (
  127. "日均成本",
  128. "评分",
  129. "建议动作",
  130. )
  131. REPORT_EXCLUDED_COLUMNS = {"关停线分位点"}
  132. def _visible_columns(
  133. sheet_name: str,
  134. expected_dates: Sequence[str] | None = None,
  135. stop_quantile: float = 0.20,
  136. ) -> list[str]:
  137. entity_type = SHEET_TO_ENTITY[sheet_name]
  138. if sheet_name in SUMMARY_SHEETS.values():
  139. columns = list(ENTITY_DIMENSIONS[entity_type]) + list(SUMMARY_METRICS)
  140. if entity_type == ENTITY_SELF:
  141. columns += ["当前创意状态"]
  142. return columns
  143. return [
  144. "dt",
  145. "渠道",
  146. *[c for c in ENTITY_DIMENSIONS[entity_type] if c != "渠道"],
  147. *DAILY_METRICS,
  148. ]
  149. def _ensure_columns(frame: pd.DataFrame, columns: Sequence[str]) -> pd.DataFrame:
  150. result = frame.copy()
  151. for column in columns:
  152. if column not in result:
  153. result[column] = ""
  154. return result
  155. def _canonical_agency_name(value: object) -> str:
  156. if pd.isna(value):
  157. return ""
  158. return re.sub(r"\s*-\s*", "-", str(value).strip())
  159. def _safe_filename_component(value: str) -> str:
  160. return re.sub(r'[\\/:*?"<>|]', "_", value).strip(" .") or "未命名代理"
  161. def _agency_summary_frame(rows: pd.DataFrame, entity_type: str) -> pd.DataFrame:
  162. frame = _summary_frame(rows, entity_type)
  163. frame["评分"] = pd.to_numeric(frame["当日效率ROI"], errors="coerce")
  164. columns = list(AGENCY_ENTITY_DIMENSIONS[entity_type]) + list(
  165. AGENCY_SUMMARY_METRICS
  166. )
  167. if entity_type == ENTITY_SELF:
  168. columns.append("当前创意状态")
  169. return _ensure_columns(frame, columns)[columns].copy()
  170. def _summary_frame(rows: pd.DataFrame, entity_type: str) -> pd.DataFrame:
  171. subset = rows[rows["entity_type"].eq(entity_type)].copy()
  172. subset = subset.rename(columns={"channel": "渠道"})
  173. subset["日均首层UV"] = pd.to_numeric(
  174. subset.get("日均首层UV"), errors="coerce"
  175. ).round().astype("Int64")
  176. subset["日均T0裂变人数"] = pd.to_numeric(
  177. subset.get("日均T0裂变数"), errors="coerce"
  178. )
  179. subset["日均首层效率收入"] = pd.to_numeric(
  180. subset.get("日均效率收入"), errors="coerce"
  181. )
  182. subset["日均T0裂变效率收入"] = pd.to_numeric(
  183. subset.get("日均T0裂变效率收入"), errors="coerce"
  184. )
  185. subset["日均总预估效率收入"] = pd.to_numeric(
  186. subset.get("日均总预估效率收入"), errors="coerce"
  187. )
  188. subset["日均T0裂变率"] = pd.to_numeric(
  189. subset.get("三日加权平均T0裂变率"), errors="coerce"
  190. )
  191. subset["三日均值ROI"] = pd.to_numeric(
  192. subset.get("三日加权平均实际ROI"), errors="coerce"
  193. )
  194. subset["当日效率ROI"] = pd.to_numeric(
  195. subset.get("最新日实际ROI"), errors="coerce"
  196. )
  197. subset["预测总效率ROI"] = pd.to_numeric(
  198. subset.get("三日加权平均效率ROI"), errors="coerce"
  199. )
  200. subset[T0_FISSION_MULTIPLIER_COLUMN] = pd.to_numeric(
  201. subset.get(DISPLAY_MULTIPLIER_COLUMN), errors="coerce"
  202. )
  203. subset[TOTAL_FISSION_TO_FIRST_UV_COLUMN] = pd.to_numeric(
  204. subset.get(DISPLAY_TOTAL_TO_FIRST_COLUMN), errors="coerce"
  205. )
  206. subset["关停线"] = pd.to_numeric(
  207. subset.get("t_stop"), errors="coerce"
  208. )
  209. subset["关停线分位点"] = pd.to_numeric(
  210. subset.get("关停线分位点"), errors="coerce"
  211. )
  212. subset["扩量线(P80)"] = (
  213. pd.to_numeric(subset.get("t_up"), errors="coerce")
  214. if entity_type == ENTITY_SELF
  215. else np.nan
  216. )
  217. subset["建议动作"] = subset.get("动作", "")
  218. if entity_type == ENTITY_SELF:
  219. subset["建议动作"] = subset["建议动作"].replace(
  220. {"关停": "关停创意"}
  221. )
  222. elif entity_type == ENTITY_SELF_AD:
  223. subset["建议动作"] = subset["建议动作"].replace(
  224. {"关停": "关停广告"}
  225. )
  226. subset["建议说明"] = subset.get("动作原因", "")
  227. visible = _visible_columns(SUMMARY_SHEETS[entity_type])
  228. required = list(visible)
  229. if entity_type in (ENTITY_SELF, ENTITY_SELF_AD):
  230. required.append("审批选择")
  231. subset = _ensure_columns(subset, required)
  232. if not subset.empty:
  233. subset["_观察排序"] = subset["阈值样本状态"].eq(
  234. "补充观察_昨日UV>200"
  235. ).astype(int)
  236. subset["_动作排序"] = (
  237. subset["建议动作"]
  238. .fillna("")
  239. .map(
  240. {
  241. "关停": 0,
  242. "关停创意": 0,
  243. "关停广告": 0,
  244. "扩量": 1,
  245. "": 2,
  246. "观察": 3,
  247. }
  248. )
  249. .fillna(4)
  250. .astype(int)
  251. )
  252. reason = subset["建议说明"].fillna("").astype(str)
  253. subset["_说明排序"] = np.select(
  254. [
  255. reason.str.contains("三日加权平均预测总效率ROI", regex=False),
  256. reason.str.contains("同时满足ROI≤", regex=False),
  257. ],
  258. [0, 1],
  259. default=2,
  260. )
  261. roi_sort = pd.to_numeric(
  262. subset["三日加权平均效率ROI"], errors="coerce"
  263. ).fillna(np.inf)
  264. subset["_ROI排序"] = np.where(
  265. subset["建议动作"].eq("扩量"), -roi_sort, roi_sort
  266. )
  267. subset["_成本排序"] = pd.to_numeric(
  268. subset["日均成本"], errors="coerce"
  269. ).fillna(0)
  270. subset["_UV排序"] = pd.to_numeric(
  271. subset["最新日首层UV"], errors="coerce"
  272. ).fillna(0)
  273. observation = subset["_观察排序"].eq(1)
  274. subset.loc[observation, "_ROI排序"] = np.inf
  275. subset.loc[observation, "_成本排序"] = 0
  276. subset = subset.sort_values(
  277. [
  278. "_观察排序",
  279. "_动作排序",
  280. "_说明排序",
  281. "_ROI排序",
  282. "_成本排序",
  283. "_UV排序",
  284. ],
  285. ascending=[True, True, True, True, False, False],
  286. kind="stable",
  287. ).drop(
  288. columns=[
  289. "_观察排序",
  290. "_动作排序",
  291. "_说明排序",
  292. "_ROI排序",
  293. "_成本排序",
  294. "_UV排序",
  295. ]
  296. )
  297. neutral = subset["建议动作"].fillna("").eq("")
  298. subset.loc[neutral, "建议动作"] = "观察"
  299. if entity_type == ENTITY_SELF:
  300. neutral_reason = (
  301. "预测总效率ROI位于动态关停线与扩量线(P80)之间,"
  302. "当前无需关停或扩量"
  303. )
  304. elif entity_type == ENTITY_SELF_AD:
  305. neutral_reason = "预测总效率ROI高于小程序动态关停线,当前无需关停整个广告"
  306. else:
  307. neutral_reason = "预测总效率ROI高于公众号独立关停线,当前仅观察"
  308. subset.loc[neutral, "建议说明"] = neutral_reason
  309. hidden = [
  310. column
  311. for column in subset.columns
  312. if column not in visible and column not in REPORT_EXCLUDED_COLUMNS
  313. ]
  314. return subset[visible + hidden]
  315. def _daily_frame(
  316. rows: pd.DataFrame,
  317. entity_type: str,
  318. expected_dates: Sequence[str],
  319. ) -> pd.DataFrame:
  320. summary = _summary_frame(rows, entity_type)
  321. daily_frames: list[pd.DataFrame] = []
  322. for dt in expected_dates:
  323. daily = summary.copy()
  324. daily["dt"] = dt
  325. daily["首层UV"] = daily.get(f"首层UV_{dt}")
  326. daily["T0裂变人数"] = daily.get(f"T0裂变数_{dt}")
  327. uv = pd.to_numeric(daily["首层UV"], errors="coerce")
  328. fission = pd.to_numeric(daily["T0裂变人数"], errors="coerce")
  329. daily["T0裂变率"] = np.where(uv.gt(0), fission / uv, np.nan)
  330. daily["首层效率收入"] = daily.get(f"效率收入_{dt}")
  331. daily["T0裂变效率收入"] = daily.get(f"裂变效率收入_{dt}")
  332. daily["预测总效率收入"] = daily.get(f"预测全链路效率收入_{dt}")
  333. daily["成本"] = daily.get(f"成本_{dt}")
  334. daily["当日效率ROI"] = daily.get(f"实际ROI_{dt}")
  335. daily["预测总效率ROI"] = daily.get(f"ROI_{dt}")
  336. daily_frames.append(daily)
  337. result = pd.concat(daily_frames, ignore_index=True) if daily_frames else summary
  338. visible = _visible_columns(DAILY_SHEETS[entity_type])
  339. result = _ensure_columns(result, visible)
  340. if not result.empty:
  341. entity_dimensions = [c for c in ENTITY_DIMENSIONS[entity_type] if c in result]
  342. result["_实体序"] = result.groupby(entity_dimensions, dropna=False, sort=False).ngroup()
  343. result["_日期序"] = result["dt"].map(
  344. {dt: i for i, dt in enumerate(reversed(expected_dates))}
  345. )
  346. result = result.sort_values(["_日期序", "_实体序"], kind="stable").drop(
  347. columns=["_实体序", "_日期序"]
  348. )
  349. hidden = [column for column in result.columns if column not in visible]
  350. return result[visible + hidden]
  351. def _sheet_frame(
  352. rows: pd.DataFrame,
  353. sheet_name: str,
  354. expected_dates: Sequence[str] | None = None,
  355. stop_quantile: float = 0.20,
  356. ) -> pd.DataFrame:
  357. entity_type = SHEET_TO_ENTITY[sheet_name]
  358. if sheet_name in SUMMARY_SHEETS.values():
  359. return _summary_frame(rows, entity_type)
  360. if expected_dates is None:
  361. raise ValueError("每日明细必须提供 expected_dates")
  362. return _daily_frame(rows, entity_type, expected_dates)
  363. def _write_dataframe(ws, frame: pd.DataFrame) -> None:
  364. ws.append(list(frame.columns))
  365. for values in frame.itertuples(index=False, name=None):
  366. ws.append(
  367. [
  368. None
  369. if isinstance(value, (float, np.floating)) and np.isnan(value)
  370. else value
  371. for value in values
  372. ]
  373. )
  374. def _number_format_for_header(header: object) -> str:
  375. name = re.sub(r"_\d{8}$", "", str(header or ""))
  376. if "占比" in name or "分位点" in name:
  377. return "0.00%"
  378. if name in {
  379. T0_FISSION_MULTIPLIER_COLUMN,
  380. TOTAL_FISSION_TO_FIRST_UV_COLUMN,
  381. }:
  382. return "0.00"
  383. lower = name.lower()
  384. is_count = (
  385. name == "dt"
  386. or lower.endswith("id")
  387. or "uv" in lower
  388. or "人数" in name
  389. or "数量" in name
  390. or name.endswith("age")
  391. or name.endswith("天数")
  392. or (name.endswith("数") and not name.endswith("系数"))
  393. )
  394. return "0" if is_count else "0.00"
  395. def _apply_number_formats(ws, headers: Mapping[object, int]) -> None:
  396. for header, column_index in headers.items():
  397. number_format = _number_format_for_header(header)
  398. for row_number in range(2, ws.max_row + 1):
  399. cell = ws.cell(row_number, column_index)
  400. if isinstance(
  401. cell.value, (int, float, np.integer, np.floating)
  402. ) and not isinstance(cell.value, bool):
  403. cell.number_format = number_format
  404. def _format_sheet(
  405. ws,
  406. visible_columns: Sequence[str],
  407. *,
  408. approval: bool = False,
  409. freeze_panes: str = "A2",
  410. ) -> None:
  411. max_column = max(ws.max_column, 1)
  412. max_row = max(ws.max_row, 1)
  413. ws.freeze_panes = freeze_panes
  414. ws.auto_filter.ref = f"A1:{get_column_letter(max_column)}{max_row}"
  415. ws.row_dimensions[1].height = 28
  416. headers = {cell.value: cell.column for cell in ws[1]}
  417. for cell in ws[1]:
  418. cell.fill = HEADER_FILL
  419. cell.font = HEADER_FONT
  420. cell.alignment = Alignment(horizontal="center", vertical="center")
  421. for column_index in range(1, max_column + 1):
  422. header = ws.cell(1, column_index).value
  423. ws.column_dimensions[get_column_letter(column_index)].hidden = (
  424. header not in visible_columns
  425. )
  426. if header in visible_columns:
  427. ws.column_dimensions[get_column_letter(column_index)].width = min(
  428. max(12, len(str(header)) * 2 + 2), 34
  429. )
  430. _apply_number_formats(ws, headers)
  431. action_column = headers.get("建议动作") or headers.get("动作")
  432. status_column = headers.get("阈值样本状态")
  433. for row_number in range(2, max_row + 1):
  434. action = ws.cell(row_number, action_column).value if action_column else ""
  435. status = ws.cell(row_number, status_column).value if status_column else ""
  436. fill = (
  437. OBSERVE_FILL
  438. if status == "补充观察_昨日UV>200" or action == "观察"
  439. else None
  440. )
  441. if fill:
  442. for column_index in range(1, len(visible_columns) + 1):
  443. ws.cell(row_number, column_index).fill = fill
  444. if action_column:
  445. def add_top_separator(row_number: int) -> None:
  446. separator = Side(style="medium", color="1F1F1F")
  447. for column_index in range(1, max_column + 1):
  448. if ws.cell(1, column_index).value in visible_columns:
  449. ws.cell(row_number, column_index).border = Border(
  450. top=separator
  451. )
  452. first_scale_row = next(
  453. (
  454. row_number
  455. for row_number in range(2, max_row + 1)
  456. if ws.cell(row_number, action_column).value == "扩量"
  457. ),
  458. None,
  459. )
  460. if first_scale_row:
  461. add_top_separator(first_scale_row)
  462. first_observe_row = next(
  463. (
  464. row_number
  465. for row_number in range(first_scale_row + 1, max_row + 1)
  466. if ws.cell(row_number, action_column).value == "观察"
  467. ),
  468. None,
  469. )
  470. if first_observe_row:
  471. add_top_separator(first_observe_row)
  472. for roi_header in ("三日均值ROI", "当日效率ROI", "预测总效率ROI"):
  473. roi_column = headers.get(roi_header)
  474. if roi_header not in visible_columns or not roi_column or max_row < 2:
  475. continue
  476. roi_letter = get_column_letter(roi_column)
  477. ws.conditional_formatting.add(
  478. f"{roi_letter}2:{roi_letter}{max_row}",
  479. ColorScaleRule(
  480. start_type="min",
  481. start_color="C00000",
  482. mid_type="percentile",
  483. mid_value=50,
  484. mid_color="FFEB84",
  485. end_type="max",
  486. end_color="00B050",
  487. ),
  488. )
  489. if approval and "审批选择" in headers and max_row >= 2:
  490. column_index = headers["审批选择"]
  491. letter = get_column_letter(column_index)
  492. ws.cell(1, column_index).fill = APPROVAL_HEADER_FILL
  493. validation = DataValidation(
  494. type="list", formula1='"批准,拒绝"', allow_blank=True
  495. )
  496. ws.add_data_validation(validation)
  497. for row_number in range(2, max_row + 1):
  498. cell = ws.cell(row_number, column_index)
  499. if cell.value in (None, ""):
  500. validation.add(cell)
  501. cell.fill = APPROVAL_FILL
  502. def _write_summary_sheet(
  503. workbook: Workbook,
  504. thresholds: pd.DataFrame,
  505. expected_dates: Sequence[str],
  506. config: Mapping[str, object],
  507. ) -> None:
  508. ws = workbook.create_sheet("运行摘要")
  509. by_type = (
  510. thresholds.set_index("entity_type").to_dict("index")
  511. if not thresholds.empty
  512. else {}
  513. )
  514. self_threshold = by_type.get(ENTITY_SELF, {})
  515. gzh_threshold = by_type.get(ENTITY_GZH, {})
  516. rows = [
  517. ("报表版本", REPORT_VERSION),
  518. ("统计窗口", f"{expected_dates[0]} 至 {expected_dates[-1]}"),
  519. ("数据深度口径", "usersharedepth<=1,与最新业务SQL一致"),
  520. ("小程序昨日总成本", self_threshold.get("小程序昨日总成本")),
  521. ("小程序目标关停成本", self_threshold.get("目标关停成本")),
  522. ("小程序实际关停成本", self_threshold.get("实际关停成本")),
  523. ("小程序实际关停成本占比", self_threshold.get("实际关停成本占比")),
  524. ("小程序关停成本预算状态", self_threshold.get("关停成本预算状态")),
  525. ("小程序三日关停线", self_threshold.get("三日关停线")),
  526. ("小程序三日关停线分位点", self_threshold.get("三日关停线分位点")),
  527. ("小程序单日P10线", self_threshold.get("单日P10线")),
  528. ("小程序单日合并资格线", self_threshold.get("单日合并资格线")),
  529. ("小程序单日实际关停线", self_threshold.get("单日实际关停线")),
  530. ("小程序单日实际关停线分位点", self_threshold.get("单日实际关停线分位点")),
  531. ("小程序三日实际关停成本", self_threshold.get("三日实际关停成本")),
  532. ("小程序单日实际关停成本", self_threshold.get("单日实际关停成本")),
  533. ("小程序单日候选池样本数", self_threshold.get("单日候选池样本数")),
  534. ("小程序单日合并候选数", self_threshold.get("单日合并候选数")),
  535. ("小程序阈值样本数", self_threshold.get("阈值样本数")),
  536. ("创意扩量线(P80)", self_threshold.get("t_up")),
  537. ("扩量样本数", self_threshold.get("扩量样本数")),
  538. ("公众号独立关停线", gzh_threshold.get("t_stop")),
  539. ("公众号关停线分位点", gzh_threshold.get("关停线分位点")),
  540. ("公众号阈值样本数", gzh_threshold.get("阈值样本数")),
  541. (
  542. "阈值样本",
  543. "小程序与公众号按渠道独立;连续三天每天首层UV>200、成本>0且ROI有效",
  544. ),
  545. ("关停年龄门槛", "所有小程序创意级和广告级关停均要求广告age>3天"),
  546. ("广告级", "直接按广告去重计算,复用小程序三日动态关停线但不进入成本预算样本池;低于关停线且广告age>3天时,审批后暂停整个广告"),
  547. ("日均字段", "三日总量/3,缺失日按0"),
  548. ("ROI与裂变率", "三日汇总分子/三日汇总分母的加权口径"),
  549. ("小程序成本软预算", "使用T-1实际成本,目标占小程序创意级昨日总成本5%;三日持续低ROI与单日补充规则按6:4基础额度分配,未用额度只在低质候选间流转"),
  550. ("单日补充规则", "严格排除已进入三日正式判断的小程序创意;剩余创意最新日UV>200、成本>0、ROI有效且广告age>3天时进入单日池,同时满足预测总效率ROI≤0.20和实体等权P10才成为关停候选"),
  551. ("补充观察", "其余非正式样本中最新日首层UV>200,置于汇总表末尾且不执行"),
  552. ("配置快照", json.dumps(dict(config), ensure_ascii=False, default=str)),
  553. ]
  554. for row in rows:
  555. ws.append(row)
  556. ws.column_dimensions["A"].width = 28
  557. ws.column_dimensions["B"].width = 110
  558. for cell in ws[1]:
  559. cell.font = Font(bold=True)
  560. for row_number in range(1, ws.max_row + 1):
  561. label = str(ws.cell(row_number, 1).value or "")
  562. value_cell = ws.cell(row_number, 2)
  563. if isinstance(value_cell.value, (int, float)) and not isinstance(
  564. value_cell.value, bool
  565. ):
  566. if "占比" in label or "分位点" in label:
  567. value_cell.number_format = "0.00%"
  568. else:
  569. value_cell.number_format = "0" if label.endswith("数") else "0.00"
  570. def write_workbook(
  571. rows: pd.DataFrame,
  572. thresholds: pd.DataFrame,
  573. expected_dates: Sequence[str],
  574. output_path: Path,
  575. config: Mapping[str, object],
  576. fission_match_summary: pd.DataFrame | None = None,
  577. ) -> None:
  578. workbook = Workbook()
  579. workbook.remove(workbook.active)
  580. for entity_type in (ENTITY_SELF, ENTITY_SELF_AD, ENTITY_GZH):
  581. sheet_name = SUMMARY_SHEETS[entity_type]
  582. frame = _summary_frame(rows, entity_type)
  583. ws = workbook.create_sheet(sheet_name)
  584. _write_dataframe(ws, frame)
  585. visible = _visible_columns(sheet_name)
  586. _format_sheet(
  587. ws,
  588. visible,
  589. approval=entity_type in (ENTITY_SELF, ENTITY_SELF_AD),
  590. freeze_panes="H2",
  591. )
  592. if entity_type == ENTITY_SELF_AD:
  593. ws.sheet_state = "hidden"
  594. for entity_type in (ENTITY_SELF, ENTITY_SELF_AD, ENTITY_GZH):
  595. sheet_name = DAILY_SHEETS[entity_type]
  596. frame = _daily_frame(rows, entity_type, expected_dates)
  597. ws = workbook.create_sheet(sheet_name)
  598. _write_dataframe(ws, frame)
  599. _format_sheet(
  600. ws,
  601. _visible_columns(sheet_name),
  602. freeze_panes="H2",
  603. )
  604. if entity_type == ENTITY_SELF_AD:
  605. ws.sheet_state = "hidden"
  606. if fission_match_summary is not None:
  607. ws = workbook.create_sheet("传播裂变系数匹配")
  608. _write_dataframe(ws, fission_match_summary)
  609. _format_sheet(ws, list(fission_match_summary.columns))
  610. ws.sheet_state = "hidden"
  611. _write_summary_sheet(workbook, thresholds, expected_dates, config)
  612. output_path.parent.mkdir(parents=True, exist_ok=True)
  613. workbook.save(output_path)
  614. def write_agency_workbooks(
  615. rows: pd.DataFrame,
  616. output_dir: Path,
  617. report_date: str,
  618. agency_names: set[str] | None = None,
  619. ) -> list[dict[str, object]]:
  620. """Create one miniapp control-advice workbook per agency."""
  621. miniapp = rows[
  622. rows["entity_type"].isin([ENTITY_SELF, ENTITY_SELF_AD])
  623. & rows["channel"].astype(str).str.startswith("小程序投流")
  624. ].copy()
  625. miniapp["_代理规范名"] = miniapp["代理名称"].map(_canonical_agency_name)
  626. agencies = sorted(name for name in miniapp["_代理规范名"].unique() if name)
  627. if agency_names is not None:
  628. canonical_names = {_canonical_agency_name(name) for name in agency_names}
  629. agencies = [name for name in agencies if name in canonical_names]
  630. output_dir.mkdir(parents=True, exist_ok=True)
  631. outputs: list[dict[str, object]] = []
  632. for agency_name in agencies:
  633. agency_rows = miniapp[miniapp["_代理规范名"].eq(agency_name)].drop(
  634. columns=["_代理规范名"]
  635. )
  636. workbook = Workbook()
  637. workbook.remove(workbook.active)
  638. for entity_type in (ENTITY_SELF, ENTITY_SELF_AD):
  639. summary_name = AGENCY_SUMMARY_SHEETS[entity_type]
  640. summary = _agency_summary_frame(agency_rows, entity_type)
  641. summary_ws = workbook.create_sheet(summary_name)
  642. _write_dataframe(summary_ws, summary)
  643. _format_sheet(summary_ws, list(summary.columns), freeze_panes="H2")
  644. if entity_type == ENTITY_SELF_AD:
  645. summary_ws.sheet_state = "hidden"
  646. filename = f"{report_date}_{_safe_filename_component(agency_name)}_调控建议.xlsx"
  647. output_path = output_dir / filename
  648. workbook.save(output_path)
  649. outputs.append(
  650. {
  651. "agency_name": agency_name,
  652. "report_version": AGENCY_REPORT_VERSION,
  653. "report": str(output_path),
  654. "creative_rows": int(
  655. agency_rows["entity_type"].eq(ENTITY_SELF).sum()
  656. ),
  657. "ad_rows": int(
  658. agency_rows["entity_type"].eq(ENTITY_SELF_AD).sum()
  659. ),
  660. }
  661. )
  662. return outputs