reporting.py 24 KB

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