| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678 |
- """Three-day ROI Excel report generation with hidden audit columns."""
- from __future__ import annotations
- import json
- import re
- from pathlib import Path
- from typing import Mapping, Sequence
- import numpy as np
- import pandas as pd
- from openpyxl import Workbook
- from openpyxl.formatting.rule import ColorScaleRule
- from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
- from openpyxl.utils import get_column_letter
- from openpyxl.worksheet.datavalidation import DataValidation
- from .fission_multiplier import (
- DISPLAY_MULTIPLIER_COLUMN,
- DISPLAY_TOTAL_TO_FIRST_COLUMN,
- )
- from .metrics import ENTITY_GZH, ENTITY_SELF, ENTITY_SELF_AD
- HEADER_FILL = PatternFill("solid", fgColor="1F4E78")
- HEADER_FONT = Font(color="FFFFFF", bold=True)
- OBSERVE_FILL = PatternFill("solid", fgColor="FFF2CC")
- APPROVAL_FILL = PatternFill("solid", fgColor="FFD966")
- APPROVAL_HEADER_FILL = PatternFill("solid", fgColor="BF9000")
- REPORT_VERSION = "roi_report_v36"
- REPORT_RUN_SUFFIX = "r36"
- AGENCY_REPORT_VERSION = "roi_agency_advice_v7"
- T0_FISSION_MULTIPLIER_COLUMN = "裂变系数-总裂变UV/T0裂变UV"
- TOTAL_FISSION_TO_FIRST_UV_COLUMN = DISPLAY_TOTAL_TO_FIRST_COLUMN
- FINAL_ROI_COLUMN = "三日加权平均效率ROI"
- SUMMARY_SHEETS = {
- ENTITY_SELF: "小程序创意级三日汇总",
- ENTITY_SELF_AD: "小程序广告级三日汇总",
- ENTITY_GZH: "公众号三日汇总",
- }
- AGENCY_SUMMARY_SHEETS = {
- ENTITY_SELF: "小程序创意调控建议",
- ENTITY_SELF_AD: SUMMARY_SHEETS[ENTITY_SELF_AD],
- }
- DAILY_SHEETS = {
- ENTITY_SELF: "小程序创意级每日明细",
- ENTITY_SELF_AD: "小程序广告级每日明细",
- ENTITY_GZH: "公众号每日明细",
- }
- SHEET_TO_ENTITY = {
- **{value: key for key, value in SUMMARY_SHEETS.items()},
- **{value: key for key, value in DAILY_SHEETS.items()},
- }
- ENTITY_DIMENSIONS = {
- ENTITY_SELF: (
- "渠道",
- "代理名称",
- "账号id",
- "账号名称",
- "广告id",
- "广告名称",
- "包名",
- "广告优化目标",
- "创意id",
- "广告age",
- ),
- ENTITY_SELF_AD: (
- "渠道",
- "代理名称",
- "账号id",
- "账号名称",
- "广告id",
- "广告名称",
- "包名",
- "广告优化目标",
- "广告age",
- ),
- ENTITY_GZH: ("渠道", "合作方名", "公众号名"),
- }
- AGENCY_ENTITY_DIMENSIONS = {
- ENTITY_SELF: (
- "渠道",
- "代理名称",
- "账号id",
- "账号名称",
- "广告id",
- "广告名称",
- "广告优化目标",
- "创意id",
- ),
- ENTITY_SELF_AD: (
- "渠道",
- "代理名称",
- "账号id",
- "账号名称",
- "广告id",
- "广告名称",
- "广告优化目标",
- ),
- }
- SUMMARY_METRICS = (
- "日均首层UV",
- "日均T0裂变人数",
- "日均T0裂变率",
- "日均首层效率收入",
- "日均T0裂变效率收入",
- "日均总预估效率收入",
- "日均成本",
- T0_FISSION_MULTIPLIER_COLUMN,
- TOTAL_FISSION_TO_FIRST_UV_COLUMN,
- "当日效率ROI",
- "预测总效率ROI",
- "关停线(P20)",
- "扩量线(P80)",
- "建议动作",
- "建议说明",
- )
- DAILY_METRICS = (
- "首层UV",
- "T0裂变人数",
- "T0裂变率",
- "首层效率收入",
- "T0裂变效率收入",
- "预测总效率收入",
- "成本",
- "当日效率ROI",
- "预测总效率ROI",
- T0_FISSION_MULTIPLIER_COLUMN,
- TOTAL_FISSION_TO_FIRST_UV_COLUMN,
- )
- AGENCY_SUMMARY_METRICS = (
- "日均成本",
- "评分",
- "建议动作",
- )
- def _visible_columns(
- sheet_name: str,
- expected_dates: Sequence[str] | None = None,
- stop_quantile: float = 0.20,
- ) -> list[str]:
- entity_type = SHEET_TO_ENTITY[sheet_name]
- if sheet_name in SUMMARY_SHEETS.values():
- columns = list(ENTITY_DIMENSIONS[entity_type]) + list(SUMMARY_METRICS)
- if entity_type == ENTITY_SELF:
- columns += ["当前创意状态"]
- return columns
- return [
- "dt",
- "渠道",
- *[c for c in ENTITY_DIMENSIONS[entity_type] if c != "渠道"],
- *DAILY_METRICS,
- ]
- def _ensure_columns(frame: pd.DataFrame, columns: Sequence[str]) -> pd.DataFrame:
- result = frame.copy()
- for column in columns:
- if column not in result:
- result[column] = ""
- return result
- def _canonical_agency_name(value: object) -> str:
- if pd.isna(value):
- return ""
- return re.sub(r"\s*-\s*", "-", str(value).strip())
- def _safe_filename_component(value: str) -> str:
- return re.sub(r'[\\/:*?"<>|]', "_", value).strip(" .") or "未命名代理"
- def _agency_summary_frame(rows: pd.DataFrame, entity_type: str) -> pd.DataFrame:
- frame = _summary_frame(rows, entity_type)
- frame["评分"] = pd.to_numeric(frame["当日效率ROI"], errors="coerce")
- columns = list(AGENCY_ENTITY_DIMENSIONS[entity_type]) + list(
- AGENCY_SUMMARY_METRICS
- )
- if entity_type == ENTITY_SELF:
- columns.append("当前创意状态")
- return _ensure_columns(frame, columns)[columns].copy()
- def _summary_frame(rows: pd.DataFrame, entity_type: str) -> pd.DataFrame:
- subset = rows[rows["entity_type"].eq(entity_type)].copy()
- subset = subset.rename(columns={"channel": "渠道"})
- subset["日均首层UV"] = pd.to_numeric(
- subset.get("日均首层UV"), errors="coerce"
- ).round().astype("Int64")
- subset["日均T0裂变人数"] = pd.to_numeric(
- subset.get("日均T0裂变数"), errors="coerce"
- )
- subset["日均首层效率收入"] = pd.to_numeric(
- subset.get("日均效率收入"), errors="coerce"
- )
- subset["日均T0裂变效率收入"] = pd.to_numeric(
- subset.get("日均T0裂变效率收入"), errors="coerce"
- )
- subset["日均总预估效率收入"] = pd.to_numeric(
- subset.get("日均总预估效率收入"), errors="coerce"
- )
- subset["日均T0裂变率"] = pd.to_numeric(
- subset.get("三日加权平均T0裂变率"), errors="coerce"
- )
- subset["当日效率ROI"] = pd.to_numeric(
- subset.get("三日加权平均实际ROI"), errors="coerce"
- )
- subset["预测总效率ROI"] = pd.to_numeric(
- subset.get("三日加权平均效率ROI"), errors="coerce"
- )
- subset[T0_FISSION_MULTIPLIER_COLUMN] = pd.to_numeric(
- subset.get(DISPLAY_MULTIPLIER_COLUMN), errors="coerce"
- )
- subset[TOTAL_FISSION_TO_FIRST_UV_COLUMN] = pd.to_numeric(
- subset.get(DISPLAY_TOTAL_TO_FIRST_COLUMN), errors="coerce"
- )
- subset["关停线(P20)"] = pd.to_numeric(
- subset.get("t_stop"), errors="coerce"
- )
- subset["扩量线(P80)"] = (
- pd.to_numeric(subset.get("t_up"), errors="coerce")
- if entity_type == ENTITY_SELF
- else np.nan
- )
- subset["建议动作"] = subset.get("动作", "")
- if entity_type == ENTITY_SELF:
- subset["建议动作"] = subset["建议动作"].replace(
- {"关停": "关停创意"}
- )
- elif entity_type == ENTITY_SELF_AD:
- subset["建议动作"] = subset["建议动作"].replace(
- {"关停": "关停广告"}
- )
- subset["建议说明"] = subset.get("动作原因", "")
- visible = _visible_columns(SUMMARY_SHEETS[entity_type])
- required = list(visible)
- if entity_type in (ENTITY_SELF, ENTITY_SELF_AD):
- required.append("审批选择")
- subset = _ensure_columns(subset, required)
- if not subset.empty:
- subset["_观察排序"] = subset["阈值样本状态"].eq(
- "补充观察_昨日UV>200"
- ).astype(int)
- subset["_动作排序"] = (
- subset["建议动作"]
- .fillna("")
- .map(
- {
- "关停": 0,
- "关停创意": 0,
- "关停广告": 0,
- "扩量": 1,
- "": 2,
- "观察": 3,
- }
- )
- .fillna(4)
- .astype(int)
- )
- reason = subset["建议说明"].fillna("").astype(str)
- subset["_说明排序"] = np.select(
- [
- reason.str.contains("三日加权平均效率ROI≤", regex=False),
- reason.str.contains("单日硬关停线", regex=False),
- reason.str.contains("单日实体等权P30", regex=False),
- ],
- [0, 1, 2],
- default=3,
- )
- roi_sort = pd.to_numeric(
- subset["三日加权平均效率ROI"], errors="coerce"
- ).fillna(np.inf)
- subset["_ROI排序"] = np.where(
- subset["建议动作"].eq("扩量"), -roi_sort, roi_sort
- )
- subset["_成本排序"] = pd.to_numeric(
- subset["日均成本"], errors="coerce"
- ).fillna(0)
- subset["_UV排序"] = pd.to_numeric(
- subset["最新日首层UV"], errors="coerce"
- ).fillna(0)
- observation = subset["_观察排序"].eq(1)
- subset.loc[observation, "_ROI排序"] = np.inf
- subset.loc[observation, "_成本排序"] = 0
- subset = subset.sort_values(
- [
- "_观察排序",
- "_动作排序",
- "_说明排序",
- "_ROI排序",
- "_成本排序",
- "_UV排序",
- ],
- ascending=[True, True, True, True, False, False],
- kind="stable",
- ).drop(
- columns=[
- "_观察排序",
- "_动作排序",
- "_说明排序",
- "_ROI排序",
- "_成本排序",
- "_UV排序",
- ]
- )
- neutral = subset["建议动作"].fillna("").eq("")
- subset.loc[neutral, "建议动作"] = "观察"
- if entity_type == ENTITY_SELF:
- neutral_reason = (
- "预测总效率ROI位于关停线(P20)与扩量线(P80)之间,"
- "当前无需关停或扩量"
- )
- elif entity_type == ENTITY_SELF_AD:
- neutral_reason = "预测总效率ROI高于关停线(P20),当前无需关停整个广告"
- else:
- neutral_reason = "预测总效率ROI高于关停线(P20),当前仅观察"
- subset.loc[neutral, "建议说明"] = neutral_reason
- hidden = [column for column in subset.columns if column not in visible]
- return subset[visible + hidden]
- def _daily_frame(
- rows: pd.DataFrame,
- entity_type: str,
- expected_dates: Sequence[str],
- ) -> pd.DataFrame:
- summary = _summary_frame(rows, entity_type)
- daily_frames: list[pd.DataFrame] = []
- for dt in expected_dates:
- daily = summary.copy()
- daily["dt"] = dt
- daily["首层UV"] = daily.get(f"首层UV_{dt}")
- daily["T0裂变人数"] = daily.get(f"T0裂变数_{dt}")
- uv = pd.to_numeric(daily["首层UV"], errors="coerce")
- fission = pd.to_numeric(daily["T0裂变人数"], errors="coerce")
- daily["T0裂变率"] = np.where(uv.gt(0), fission / uv, np.nan)
- daily["首层效率收入"] = daily.get(f"效率收入_{dt}")
- daily["T0裂变效率收入"] = daily.get(f"裂变效率收入_{dt}")
- daily["预测总效率收入"] = daily.get(f"预测全链路效率收入_{dt}")
- daily["成本"] = daily.get(f"成本_{dt}")
- daily["当日效率ROI"] = daily.get(f"实际ROI_{dt}")
- daily["预测总效率ROI"] = daily.get(f"ROI_{dt}")
- daily_frames.append(daily)
- result = pd.concat(daily_frames, ignore_index=True) if daily_frames else summary
- visible = _visible_columns(DAILY_SHEETS[entity_type])
- result = _ensure_columns(result, visible)
- if not result.empty:
- entity_dimensions = [c for c in ENTITY_DIMENSIONS[entity_type] if c in result]
- result["_实体序"] = result.groupby(entity_dimensions, dropna=False, sort=False).ngroup()
- result["_日期序"] = result["dt"].map(
- {dt: i for i, dt in enumerate(reversed(expected_dates))}
- )
- result = result.sort_values(["_日期序", "_实体序"], kind="stable").drop(
- columns=["_实体序", "_日期序"]
- )
- hidden = [column for column in result.columns if column not in visible]
- return result[visible + hidden]
- def _sheet_frame(
- rows: pd.DataFrame,
- sheet_name: str,
- expected_dates: Sequence[str] | None = None,
- stop_quantile: float = 0.20,
- ) -> pd.DataFrame:
- entity_type = SHEET_TO_ENTITY[sheet_name]
- if sheet_name in SUMMARY_SHEETS.values():
- return _summary_frame(rows, entity_type)
- if expected_dates is None:
- raise ValueError("每日明细必须提供 expected_dates")
- return _daily_frame(rows, entity_type, expected_dates)
- def _write_dataframe(ws, frame: pd.DataFrame) -> None:
- ws.append(list(frame.columns))
- for values in frame.itertuples(index=False, name=None):
- ws.append(
- [
- None
- if isinstance(value, (float, np.floating)) and np.isnan(value)
- else value
- for value in values
- ]
- )
- def _number_format_for_header(header: object) -> str:
- name = re.sub(r"_\d{8}$", "", str(header or ""))
- if name in {
- T0_FISSION_MULTIPLIER_COLUMN,
- TOTAL_FISSION_TO_FIRST_UV_COLUMN,
- }:
- return "0.00"
- lower = name.lower()
- is_count = (
- name == "dt"
- or lower.endswith("id")
- or "uv" in lower
- or "人数" in name
- or "数量" in name
- or name.endswith("age")
- or name.endswith("天数")
- or (name.endswith("数") and not name.endswith("系数"))
- )
- return "0" if is_count else "0.00"
- def _apply_number_formats(ws, headers: Mapping[object, int]) -> None:
- for header, column_index in headers.items():
- number_format = _number_format_for_header(header)
- for row_number in range(2, ws.max_row + 1):
- cell = ws.cell(row_number, column_index)
- if isinstance(
- cell.value, (int, float, np.integer, np.floating)
- ) and not isinstance(cell.value, bool):
- cell.number_format = number_format
- def _format_sheet(
- ws,
- visible_columns: Sequence[str],
- *,
- approval: bool = False,
- freeze_panes: str = "A2",
- ) -> None:
- max_column = max(ws.max_column, 1)
- max_row = max(ws.max_row, 1)
- ws.freeze_panes = freeze_panes
- ws.auto_filter.ref = f"A1:{get_column_letter(max_column)}{max_row}"
- ws.row_dimensions[1].height = 28
- headers = {cell.value: cell.column for cell in ws[1]}
- for cell in ws[1]:
- cell.fill = HEADER_FILL
- cell.font = HEADER_FONT
- cell.alignment = Alignment(horizontal="center", vertical="center")
- for column_index in range(1, max_column + 1):
- header = ws.cell(1, column_index).value
- ws.column_dimensions[get_column_letter(column_index)].hidden = (
- header not in visible_columns
- )
- if header in visible_columns:
- ws.column_dimensions[get_column_letter(column_index)].width = min(
- max(12, len(str(header)) * 2 + 2), 34
- )
- _apply_number_formats(ws, headers)
- action_column = headers.get("建议动作") or headers.get("动作")
- status_column = headers.get("阈值样本状态")
- for row_number in range(2, max_row + 1):
- action = ws.cell(row_number, action_column).value if action_column else ""
- status = ws.cell(row_number, status_column).value if status_column else ""
- fill = (
- OBSERVE_FILL
- if status == "补充观察_昨日UV>200" or action == "观察"
- else None
- )
- if fill:
- for column_index in range(1, len(visible_columns) + 1):
- ws.cell(row_number, column_index).fill = fill
- if action_column:
- def add_top_separator(row_number: int) -> None:
- separator = Side(style="medium", color="1F1F1F")
- for column_index in range(1, max_column + 1):
- if ws.cell(1, column_index).value in visible_columns:
- ws.cell(row_number, column_index).border = Border(
- top=separator
- )
- first_scale_row = next(
- (
- row_number
- for row_number in range(2, max_row + 1)
- if ws.cell(row_number, action_column).value == "扩量"
- ),
- None,
- )
- if first_scale_row:
- add_top_separator(first_scale_row)
- first_observe_row = next(
- (
- row_number
- for row_number in range(first_scale_row + 1, max_row + 1)
- if ws.cell(row_number, action_column).value == "观察"
- ),
- None,
- )
- if first_observe_row:
- add_top_separator(first_observe_row)
- for roi_header in ("当日效率ROI", "预测总效率ROI"):
- roi_column = headers.get(roi_header)
- if not roi_column or max_row < 2:
- continue
- roi_letter = get_column_letter(roi_column)
- ws.conditional_formatting.add(
- f"{roi_letter}2:{roi_letter}{max_row}",
- ColorScaleRule(
- start_type="min",
- start_color="C00000",
- mid_type="percentile",
- mid_value=50,
- mid_color="FFEB84",
- end_type="max",
- end_color="00B050",
- ),
- )
- if approval and "审批选择" in headers and max_row >= 2:
- column_index = headers["审批选择"]
- letter = get_column_letter(column_index)
- ws.cell(1, column_index).fill = APPROVAL_HEADER_FILL
- validation = DataValidation(
- type="list", formula1='"批准,拒绝"', allow_blank=True
- )
- ws.add_data_validation(validation)
- for row_number in range(2, max_row + 1):
- cell = ws.cell(row_number, column_index)
- if cell.value in (None, ""):
- validation.add(cell)
- cell.fill = APPROVAL_FILL
- def _write_summary_sheet(
- workbook: Workbook,
- thresholds: pd.DataFrame,
- expected_dates: Sequence[str],
- config: Mapping[str, object],
- ) -> None:
- ws = workbook.create_sheet("运行摘要")
- threshold = thresholds.iloc[0].to_dict() if not thresholds.empty else {}
- rows = [
- ("报表版本", REPORT_VERSION),
- ("统计窗口", f"{expected_dates[0]} 至 {expected_dates[-1]}"),
- ("数据深度口径", "usersharedepth<=1,与最新业务SQL一致"),
- ("关停线(P20)", threshold.get("t_stop")),
- ("阈值样本数", threshold.get("阈值样本数")),
- ("创意扩量线(P80)", threshold.get("t_up")),
- ("扩量样本数", threshold.get("扩量样本数")),
- ("单日关停线(P30)", threshold.get("t_one_day_stop")),
- ("单日P30样本数", threshold.get("单日P30样本数")),
- ("阈值样本", "连续三天每天首层UV>200、成本>0且ROI有效的小程序创意和公众号实体"),
- ("关停年龄门槛", "所有小程序创意级和广告级关停均要求广告age>3天"),
- ("广告级", "直接按广告去重计算,复用统一P20但不进入样本池;低于关停线且广告age>3天时,审批后暂停整个广告"),
- ("日均字段", "三日总量/3,缺失日按0"),
- ("ROI与裂变率", "三日汇总分子/三日汇总分母的加权口径"),
- ("单日补充规则", "非三日正式样本的小程序创意:最新日UV>200且ROI≤0.20,或UV>500且ROI≤实体等权P30;广告age>3天时建议关停,否则观察"),
- ("补充观察", "其余非正式样本中最新日首层UV>200,置于汇总表末尾且不执行"),
- ("配置快照", json.dumps(dict(config), ensure_ascii=False, default=str)),
- ]
- for row in rows:
- ws.append(row)
- ws.column_dimensions["A"].width = 28
- ws.column_dimensions["B"].width = 110
- for cell in ws[1]:
- cell.font = Font(bold=True)
- for row_number in range(1, ws.max_row + 1):
- label = str(ws.cell(row_number, 1).value or "")
- value_cell = ws.cell(row_number, 2)
- if isinstance(value_cell.value, (int, float)) and not isinstance(
- value_cell.value, bool
- ):
- value_cell.number_format = "0" if label.endswith("数") else "0.00"
- def write_workbook(
- rows: pd.DataFrame,
- thresholds: pd.DataFrame,
- expected_dates: Sequence[str],
- output_path: Path,
- config: Mapping[str, object],
- fission_match_summary: pd.DataFrame | None = None,
- ) -> None:
- workbook = Workbook()
- workbook.remove(workbook.active)
- for entity_type in (ENTITY_SELF, ENTITY_SELF_AD, ENTITY_GZH):
- sheet_name = SUMMARY_SHEETS[entity_type]
- frame = _summary_frame(rows, entity_type)
- ws = workbook.create_sheet(sheet_name)
- _write_dataframe(ws, frame)
- visible = _visible_columns(sheet_name)
- _format_sheet(
- ws,
- visible,
- approval=entity_type in (ENTITY_SELF, ENTITY_SELF_AD),
- freeze_panes="H2",
- )
- if entity_type == ENTITY_SELF_AD:
- ws.sheet_state = "hidden"
- for entity_type in (ENTITY_SELF, ENTITY_SELF_AD, ENTITY_GZH):
- sheet_name = DAILY_SHEETS[entity_type]
- frame = _daily_frame(rows, entity_type, expected_dates)
- ws = workbook.create_sheet(sheet_name)
- _write_dataframe(ws, frame)
- _format_sheet(
- ws,
- _visible_columns(sheet_name),
- freeze_panes="H2",
- )
- if entity_type == ENTITY_SELF_AD:
- ws.sheet_state = "hidden"
- if fission_match_summary is not None:
- ws = workbook.create_sheet("传播裂变系数匹配")
- _write_dataframe(ws, fission_match_summary)
- _format_sheet(ws, list(fission_match_summary.columns))
- ws.sheet_state = "hidden"
- _write_summary_sheet(workbook, thresholds, expected_dates, config)
- output_path.parent.mkdir(parents=True, exist_ok=True)
- workbook.save(output_path)
- def write_agency_workbooks(
- rows: pd.DataFrame,
- output_dir: Path,
- report_date: str,
- agency_names: set[str] | None = None,
- ) -> list[dict[str, object]]:
- """Create one miniapp control-advice workbook per agency."""
- miniapp = rows[
- rows["entity_type"].isin([ENTITY_SELF, ENTITY_SELF_AD])
- & rows["channel"].astype(str).str.startswith("小程序投流")
- ].copy()
- miniapp["_代理规范名"] = miniapp["代理名称"].map(_canonical_agency_name)
- agencies = sorted(name for name in miniapp["_代理规范名"].unique() if name)
- if agency_names is not None:
- canonical_names = {_canonical_agency_name(name) for name in agency_names}
- agencies = [name for name in agencies if name in canonical_names]
- output_dir.mkdir(parents=True, exist_ok=True)
- outputs: list[dict[str, object]] = []
- for agency_name in agencies:
- agency_rows = miniapp[miniapp["_代理规范名"].eq(agency_name)].drop(
- columns=["_代理规范名"]
- )
- workbook = Workbook()
- workbook.remove(workbook.active)
- for entity_type in (ENTITY_SELF, ENTITY_SELF_AD):
- summary_name = AGENCY_SUMMARY_SHEETS[entity_type]
- summary = _agency_summary_frame(agency_rows, entity_type)
- summary_ws = workbook.create_sheet(summary_name)
- _write_dataframe(summary_ws, summary)
- _format_sheet(summary_ws, list(summary.columns), freeze_panes="H2")
- if entity_type == ENTITY_SELF_AD:
- summary_ws.sheet_state = "hidden"
- filename = f"{report_date}_{_safe_filename_component(agency_name)}_调控建议.xlsx"
- output_path = output_dir / filename
- workbook.save(output_path)
- outputs.append(
- {
- "agency_name": agency_name,
- "report_version": AGENCY_REPORT_VERSION,
- "report": str(output_path),
- "creative_rows": int(
- agency_rows["entity_type"].eq(ENTITY_SELF).sum()
- ),
- "ad_rows": int(
- agency_rows["entity_type"].eq(ENTITY_SELF_AD).sum()
- ),
- }
- )
- return outputs
|