|
|
@@ -1,46 +1,61 @@
|
|
|
-"""调控结果 Excel 报告生成。"""
|
|
|
+"""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 Dict, Mapping, Sequence
|
|
|
+from typing import Mapping, Sequence
|
|
|
|
|
|
import numpy as np
|
|
|
import pandas as pd
|
|
|
from openpyxl import Workbook
|
|
|
-from openpyxl.worksheet.datavalidation import DataValidation
|
|
|
from openpyxl.formatting.rule import ColorScaleRule
|
|
|
-from openpyxl.styles import Alignment, Font, PatternFill
|
|
|
+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_QIWEI, ENTITY_SELF
|
|
|
+from .metrics import ENTITY_GZH, ENTITY_SELF, ENTITY_SELF_AD
|
|
|
|
|
|
|
|
|
HEADER_FILL = PatternFill("solid", fgColor="1F4E78")
|
|
|
HEADER_FONT = Font(color="FFFFFF", bold=True)
|
|
|
-STOP_FILL = PatternFill("solid", fgColor="F4CCCC")
|
|
|
-UP_FILL = PatternFill("solid", fgColor="D9EAD3")
|
|
|
-ADJUST_FILL = PatternFill("solid", fgColor="FFF2CC")
|
|
|
OBSERVE_FILL = PatternFill("solid", fgColor="FFF2CC")
|
|
|
APPROVAL_FILL = PatternFill("solid", fgColor="FFD966")
|
|
|
APPROVAL_HEADER_FILL = PatternFill("solid", fgColor="BF9000")
|
|
|
-REPORT_VERSION = "roi_report_v15"
|
|
|
-REPORT_RUN_SUFFIX = "r15"
|
|
|
+
|
|
|
+REPORT_VERSION = "roi_report_v29"
|
|
|
+REPORT_RUN_SUFFIX = "r29"
|
|
|
|
|
|
T0_FISSION_MULTIPLIER_COLUMN = "裂变系数-总裂变UV/T0裂变UV"
|
|
|
TOTAL_FISSION_TO_FIRST_UV_COLUMN = DISPLAY_TOTAL_TO_FIRST_COLUMN
|
|
|
-FINAL_ROI_COLUMN = "最终效率ROI"
|
|
|
+FINAL_ROI_COLUMN = "三日加权平均效率ROI"
|
|
|
|
|
|
+SUMMARY_SHEETS = {
|
|
|
+ ENTITY_SELF: "小程序创意级三日汇总",
|
|
|
+ ENTITY_SELF_AD: "小程序广告级三日汇总",
|
|
|
+ ENTITY_GZH: "公众号三日汇总",
|
|
|
+}
|
|
|
+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()},
|
|
|
+}
|
|
|
|
|
|
-BASE_COLUMNS: Dict[str, Sequence[str]] = {
|
|
|
- "小程序投流": (
|
|
|
- "dt",
|
|
|
+ENTITY_DIMENSIONS = {
|
|
|
+ ENTITY_SELF: (
|
|
|
"渠道",
|
|
|
"代理名称",
|
|
|
"账号id",
|
|
|
+ "账号名称",
|
|
|
"广告id",
|
|
|
"广告名称",
|
|
|
"包名",
|
|
|
@@ -48,20 +63,38 @@ BASE_COLUMNS: Dict[str, Sequence[str]] = {
|
|
|
"创意id",
|
|
|
"广告age",
|
|
|
),
|
|
|
- "公众号即转": (
|
|
|
- "dt",
|
|
|
+ ENTITY_SELF_AD: (
|
|
|
"渠道",
|
|
|
- "合作方名",
|
|
|
- "公众号名",
|
|
|
- ),
|
|
|
- "企微群合作": (
|
|
|
- "dt",
|
|
|
- "渠道",
|
|
|
- "合作方名",
|
|
|
+ "代理名称",
|
|
|
+ "账号id",
|
|
|
+ "账号名称",
|
|
|
+ "广告id",
|
|
|
+ "广告名称",
|
|
|
+ "包名",
|
|
|
+ "广告优化目标",
|
|
|
+ "广告age",
|
|
|
),
|
|
|
+ ENTITY_GZH: ("渠道", "合作方名", "公众号名"),
|
|
|
}
|
|
|
|
|
|
-DAILY_COLUMNS = (
|
|
|
+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裂变率",
|
|
|
@@ -69,109 +102,66 @@ DAILY_COLUMNS = (
|
|
|
"T0裂变效率收入",
|
|
|
"预测总效率收入",
|
|
|
"成本",
|
|
|
- "效率ROI",
|
|
|
+ "当日效率ROI",
|
|
|
+ "预测总效率ROI",
|
|
|
T0_FISSION_MULTIPLIER_COLUMN,
|
|
|
TOTAL_FISSION_TO_FIRST_UV_COLUMN,
|
|
|
- "消耗加权分位",
|
|
|
-)
|
|
|
-
|
|
|
-DECISION_COLUMNS: Dict[str, Sequence[str]] = {
|
|
|
- "小程序投流": (
|
|
|
- "动作",
|
|
|
- "动作原因",
|
|
|
- "阈值样本状态",
|
|
|
- "审批选择",
|
|
|
- "执行状态",
|
|
|
- "执行结果",
|
|
|
- ),
|
|
|
- "公众号即转": (
|
|
|
- "动作",
|
|
|
- "动作原因",
|
|
|
- "阈值样本状态",
|
|
|
- ),
|
|
|
- "企微群合作": (),
|
|
|
-}
|
|
|
-
|
|
|
-QIWEI_STATUS_COLUMNS = (
|
|
|
- "传播裂变参数状态",
|
|
|
- "调控参与状态",
|
|
|
)
|
|
|
|
|
|
-ENTITY_TO_SHEET = {
|
|
|
- ENTITY_SELF: "小程序投流",
|
|
|
- ENTITY_GZH: "公众号即转",
|
|
|
- ENTITY_QIWEI: "企微群合作",
|
|
|
-}
|
|
|
-
|
|
|
-FREEZE_PANES = {
|
|
|
- "小程序投流": "G2",
|
|
|
- "公众号即转": "E2",
|
|
|
- "企微群合作": "D2",
|
|
|
-}
|
|
|
-
|
|
|
|
|
|
def _visible_columns(
|
|
|
sheet_name: str,
|
|
|
- expected_dates: Sequence[str],
|
|
|
- stop_quantile: float,
|
|
|
+ expected_dates: Sequence[str] | None = None,
|
|
|
+ stop_quantile: float = 0.20,
|
|
|
) -> list[str]:
|
|
|
- stop_label = f"P{int(stop_quantile * 100)}"
|
|
|
- threshold_columns = (
|
|
|
- []
|
|
|
- if sheet_name == "企微群合作"
|
|
|
- else [f"消耗加权{stop_label}线"]
|
|
|
- )
|
|
|
- status_columns = (
|
|
|
- list(QIWEI_STATUS_COLUMNS)
|
|
|
- if sheet_name == "企微群合作"
|
|
|
- else list(DECISION_COLUMNS[sheet_name])
|
|
|
- )
|
|
|
- return (
|
|
|
- list(BASE_COLUMNS[sheet_name])
|
|
|
- + list(DAILY_COLUMNS)
|
|
|
- + threshold_columns
|
|
|
- + status_columns
|
|
|
- )
|
|
|
+ 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 _sheet_frame(
|
|
|
- candidates: pd.DataFrame,
|
|
|
- sheet_name: str,
|
|
|
- expected_dates: Sequence[str] | None = None,
|
|
|
- stop_quantile: float = 0.25,
|
|
|
-) -> pd.DataFrame:
|
|
|
- entity_type = next(
|
|
|
- key for key, value in ENTITY_TO_SHEET.items() if value == sheet_name
|
|
|
- )
|
|
|
- subset = candidates[candidates["entity_type"].eq(entity_type)].copy()
|
|
|
+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 _summary_frame(rows: pd.DataFrame, entity_type: str) -> pd.DataFrame:
|
|
|
+ subset = rows[rows["entity_type"].eq(entity_type)].copy()
|
|
|
subset = subset.rename(columns={"channel": "渠道"})
|
|
|
- coverage_source = (
|
|
|
- subset["覆盖天数"]
|
|
|
- if "覆盖天数" in subset
|
|
|
- else pd.Series(2, index=subset.index)
|
|
|
- )
|
|
|
- coverage_days = pd.to_numeric(
|
|
|
- coverage_source, errors="coerce"
|
|
|
- ).replace(0, np.nan)
|
|
|
subset["日均首层UV"] = pd.to_numeric(
|
|
|
subset.get("日均首层UV"), errors="coerce"
|
|
|
).round().astype("Int64")
|
|
|
- subset["首层效率收入"] = (
|
|
|
- pd.to_numeric(subset.get("效率收入"), errors="coerce")
|
|
|
- / coverage_days
|
|
|
+ subset["日均T0裂变人数"] = pd.to_numeric(
|
|
|
+ subset.get("日均T0裂变数"), errors="coerce"
|
|
|
)
|
|
|
- subset["T0裂变效率收入"] = (
|
|
|
- pd.to_numeric(subset.get("T0实际裂变收入"), errors="coerce")
|
|
|
- / coverage_days
|
|
|
+ subset["日均首层效率收入"] = pd.to_numeric(
|
|
|
+ subset.get("日均效率收入"), errors="coerce"
|
|
|
)
|
|
|
- subset["总预估效率收入"] = (
|
|
|
- pd.to_numeric(
|
|
|
- subset.get("预测全链路效率收入"), errors="coerce"
|
|
|
- )
|
|
|
- / coverage_days
|
|
|
+ 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["日均成本"] = (
|
|
|
- pd.to_numeric(subset.get("成本"), errors="coerce") / coverage_days
|
|
|
+ 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"
|
|
|
@@ -179,94 +169,106 @@ def _sheet_frame(
|
|
|
subset[TOTAL_FISSION_TO_FIRST_UV_COLUMN] = pd.to_numeric(
|
|
|
subset.get(DISPLAY_TOTAL_TO_FIRST_COLUMN), errors="coerce"
|
|
|
)
|
|
|
- subset[FINAL_ROI_COLUMN] = subset["ROI"]
|
|
|
- subset["关停线"] = subset["t_stop"]
|
|
|
- subset["扩量线"] = subset["t_up"]
|
|
|
- dates = list(expected_dates or sorted(
|
|
|
- column.removeprefix("ROI_")
|
|
|
- for column in subset.columns
|
|
|
- if column.startswith("ROI_") and len(column) == 12
|
|
|
- ))
|
|
|
- stop_label = f"P{int(stop_quantile * 100)}"
|
|
|
- daily_frames = []
|
|
|
- latest_date = dates[-1] if dates else ""
|
|
|
- for dt in dates:
|
|
|
- daily = subset.copy()
|
|
|
+ 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("动作", "")
|
|
|
+ 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, "扩量": 1, "": 2, "观察": 3})
|
|
|
+ .fillna(4)
|
|
|
+ .astype(int)
|
|
|
+ )
|
|
|
+ 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, False, False],
|
|
|
+ kind="stable",
|
|
|
+ ).drop(columns=["_观察排序", "_动作排序", "_ROI排序", "_成本排序", "_UV排序"])
|
|
|
+ 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}")
|
|
|
- daily["T0裂变率"] = np.where(
|
|
|
- pd.to_numeric(daily["首层UV"], errors="coerce").gt(0),
|
|
|
- pd.to_numeric(daily["T0裂变人数"], errors="coerce")
|
|
|
- / pd.to_numeric(daily["首层UV"], errors="coerce"),
|
|
|
- np.nan,
|
|
|
- )
|
|
|
+ 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["预测总效率收入"] = (
|
|
|
- pd.to_numeric(daily["效率ROI"], errors="coerce")
|
|
|
- * pd.to_numeric(daily["成本"], errors="coerce")
|
|
|
- )
|
|
|
- daily["消耗加权分位"] = daily.get(f"消耗加权同日分位_{dt}")
|
|
|
- if sheet_name != "企微群合作":
|
|
|
- daily[f"消耗加权{stop_label}线"] = daily.get(f"t_stop_{dt}")
|
|
|
- if dt != latest_date:
|
|
|
- for column in (
|
|
|
- "动作",
|
|
|
- "动作原因",
|
|
|
- "阈值样本状态",
|
|
|
- "执行状态",
|
|
|
- "执行结果",
|
|
|
- "动作幂等键",
|
|
|
- "执行模式",
|
|
|
- "执行说明",
|
|
|
- ):
|
|
|
- daily[column] = ""
|
|
|
- daily["审批选择"] = "历史日"
|
|
|
+ daily["当日效率ROI"] = daily.get(f"实际ROI_{dt}")
|
|
|
+ daily["预测总效率ROI"] = daily.get(f"ROI_{dt}")
|
|
|
daily_frames.append(daily)
|
|
|
- if daily_frames:
|
|
|
- subset = pd.concat(daily_frames, ignore_index=True)
|
|
|
- else:
|
|
|
- subset["dt"] = ""
|
|
|
- visible_columns = _visible_columns(sheet_name, dates, stop_quantile)
|
|
|
- for column in visible_columns:
|
|
|
- if column not in subset:
|
|
|
- subset[column] = ""
|
|
|
- hidden_columns = [
|
|
|
- column for column in subset.columns if column not in visible_columns
|
|
|
- ]
|
|
|
- ordered = visible_columns + hidden_columns
|
|
|
- subset = subset[ordered]
|
|
|
- if not subset.empty:
|
|
|
- subset["_日期排序"] = subset["dt"].map(
|
|
|
- {dt: position for position, dt in enumerate(reversed(dates))}
|
|
|
- ).fillna(len(dates))
|
|
|
- subset["_补充观察排序"] = (
|
|
|
- subset["阈值样本状态"].eq("观察_最新日UV达标").astype(int)
|
|
|
- )
|
|
|
- action_order = {"关停": 0, "扩量": 1, "观察": 2, "": 3}
|
|
|
- subset["_动作排序"] = (
|
|
|
- subset["动作"].fillna("").map(action_order).fillna(4)
|
|
|
+ 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))}
|
|
|
)
|
|
|
- subset["_当日成本排序"] = pd.to_numeric(
|
|
|
- subset.get("成本"),
|
|
|
- errors="coerce",
|
|
|
- ).fillna(0)
|
|
|
- subset = subset.sort_values(
|
|
|
- ["_日期排序", "_补充观察排序", "_动作排序", "_当日成本排序"],
|
|
|
- ascending=[True, True, True, False],
|
|
|
- kind="stable",
|
|
|
- ).drop(
|
|
|
- columns=[
|
|
|
- "_日期排序",
|
|
|
- "_补充观察排序",
|
|
|
- "_动作排序",
|
|
|
- "_当日成本排序",
|
|
|
- ]
|
|
|
+ result = result.sort_values(["_日期序", "_实体序"], kind="stable").drop(
|
|
|
+ columns=["_实体序", "_日期序"]
|
|
|
)
|
|
|
- return subset
|
|
|
+ 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:
|
|
|
@@ -274,308 +276,234 @@ def _write_dataframe(ws, frame: pd.DataFrame) -> None:
|
|
|
for values in frame.itertuples(index=False, name=None):
|
|
|
ws.append(
|
|
|
[
|
|
|
- None if isinstance(value, float) and np.isnan(value) else value
|
|
|
+ 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,
|
|
|
- sheet_name: str,
|
|
|
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[sheet_name]
|
|
|
+ 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 = {}
|
|
|
+ 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")
|
|
|
- headers[cell.value] = cell.column
|
|
|
+ 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_no_action_row = next(
|
|
|
+ (
|
|
|
+ row_number
|
|
|
+ for row_number in range(first_scale_row + 1, max_row + 1)
|
|
|
+ if not (ws.cell(row_number, action_column).value or "")
|
|
|
+ ),
|
|
|
+ None,
|
|
|
+ )
|
|
|
+ if first_no_action_row:
|
|
|
+ add_top_separator(first_no_action_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",
|
|
|
+ ),
|
|
|
+ )
|
|
|
|
|
|
- approval_column = headers.get("审批选择")
|
|
|
- if approval_column:
|
|
|
- approval_header = ws.cell(1, approval_column)
|
|
|
- approval_header.fill = APPROVAL_HEADER_FILL
|
|
|
- approval_header.font = HEADER_FONT
|
|
|
+ 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,
|
|
|
+ type="list", formula1='"批准,拒绝"', allow_blank=True
|
|
|
)
|
|
|
- validation.error = "请选择批准或拒绝"
|
|
|
- validation.errorTitle = "审批值无效"
|
|
|
ws.add_data_validation(validation)
|
|
|
- for row in range(2, max_row + 1):
|
|
|
- cell = ws.cell(row, approval_column)
|
|
|
+ for row_number in range(2, max_row + 1):
|
|
|
+ cell = ws.cell(row_number, column_index)
|
|
|
if cell.value in (None, ""):
|
|
|
- cell.fill = APPROVAL_FILL
|
|
|
validation.add(cell)
|
|
|
+ cell.fill = APPROVAL_FILL
|
|
|
|
|
|
- for column_index in range(1, max_column + 1):
|
|
|
- header = ws.cell(1, column_index).value or ""
|
|
|
- values = [str(ws.cell(row, column_index).value or "") for row in range(1, max_row + 1)]
|
|
|
- width = min(max(max(map(len, values)), len(str(header))) + 2, 42)
|
|
|
- ws.column_dimensions[get_column_letter(column_index)].width = max(width, 11)
|
|
|
-
|
|
|
- if (
|
|
|
- "ROI" in str(header)
|
|
|
- or "裂变率" in str(header)
|
|
|
- or "百分位" in str(header)
|
|
|
- or "分位" in str(header)
|
|
|
- or "关停线" in str(header)
|
|
|
- or str(header).startswith("裂变系数-")
|
|
|
- ):
|
|
|
- for row in range(2, max_row + 1):
|
|
|
- ws.cell(row, column_index).number_format = "0.000"
|
|
|
- elif "成本" in str(header) or header in {
|
|
|
- "首层效率收入",
|
|
|
- "T0裂变效率收入",
|
|
|
- "总预估效率收入",
|
|
|
- "效率收入",
|
|
|
- "实际全链路效率收入",
|
|
|
- "裂变效率收入",
|
|
|
- "T0实际裂变收入",
|
|
|
- "预测T1-T15裂变收入",
|
|
|
- "预测T0-T15裂变收入",
|
|
|
- "预测全链路效率收入",
|
|
|
- }:
|
|
|
- for row in range(2, max_row + 1):
|
|
|
- ws.cell(row, column_index).number_format = "#,##0.00"
|
|
|
- elif "UV" in str(header) or "裂变数" in str(header):
|
|
|
- for row in range(2, max_row + 1):
|
|
|
- ws.cell(row, column_index).number_format = "#,##0"
|
|
|
-
|
|
|
- if "ROI" in str(header) and max_row >= 2:
|
|
|
- letter = get_column_letter(column_index)
|
|
|
- ws.conditional_formatting.add(
|
|
|
- f"{letter}2:{letter}{max_row}",
|
|
|
- ColorScaleRule(
|
|
|
- start_type="min",
|
|
|
- start_color="F8696B",
|
|
|
- mid_type="percentile",
|
|
|
- mid_value=50,
|
|
|
- mid_color="FFEB84",
|
|
|
- end_type="max",
|
|
|
- end_color="63BE7B",
|
|
|
- ),
|
|
|
- )
|
|
|
-
|
|
|
- if header not in set(visible_columns):
|
|
|
- ws.column_dimensions[get_column_letter(column_index)].hidden = True
|
|
|
|
|
|
- action_column = headers.get("动作")
|
|
|
- if action_column:
|
|
|
- for row in range(2, max_row + 1):
|
|
|
- action = ws.cell(row, action_column).value
|
|
|
- fill = {
|
|
|
- "关停": STOP_FILL,
|
|
|
- "扩量": UP_FILL,
|
|
|
- "调整封面&落地页视频": ADJUST_FILL,
|
|
|
- "观察": OBSERVE_FILL,
|
|
|
- }.get(action)
|
|
|
- if fill:
|
|
|
- ws.cell(row, action_column).fill = fill
|
|
|
-
|
|
|
-def _write_summary(
|
|
|
+def _write_summary_sheet(
|
|
|
workbook: Workbook,
|
|
|
thresholds: pd.DataFrame,
|
|
|
- candidates: pd.DataFrame,
|
|
|
expected_dates: Sequence[str],
|
|
|
- rule_config: Mapping[str, object] | None,
|
|
|
+ config: Mapping[str, object],
|
|
|
) -> None:
|
|
|
ws = workbook.create_sheet("运行摘要")
|
|
|
- self_min_uv = (rule_config or {}).get("self_min_daily_uv", 200)
|
|
|
- partner_min_uv = (rule_config or {}).get("partner_min_daily_uv", 200)
|
|
|
- observe_min_uv = (rule_config or {}).get("observe_min_latest_uv", 100)
|
|
|
- stop_quantile = float((rule_config or {}).get("stop_quantile", 0.25))
|
|
|
- up_quantile = float((rule_config or {}).get("up_quantile", 0.80))
|
|
|
- weight_cap_quantile = float(
|
|
|
- (rule_config or {}).get("stop_weight_cap_quantile", 0.95)
|
|
|
- )
|
|
|
+ threshold = thresholds.iloc[0].to_dict() if not thresholds.empty else {}
|
|
|
rows = [
|
|
|
+ ("报表版本", REPORT_VERSION),
|
|
|
("统计窗口", f"{expected_dates[0]} 至 {expected_dates[-1]}"),
|
|
|
- ("首层口径", "usersharedepth='0'"),
|
|
|
- ("UV口径", "COUNT(DISTINCT mid)"),
|
|
|
- (
|
|
|
- "ROI口径",
|
|
|
- "源表裂变效率收入为T0当天实际值;预测总收入=首层效率收入+T0实际裂变收入+T0实际裂变收入×(传播裂变系数-1)",
|
|
|
- ),
|
|
|
- (
|
|
|
- "企微口径",
|
|
|
- "企微按合作方匹配已发布参数;未匹配或样本不足时默认取2.5。"
|
|
|
- "企微仅展示,不进入阈值样本池和调控。",
|
|
|
- ),
|
|
|
- (
|
|
|
- "阈值口径",
|
|
|
- "小程序与公众号分渠道、分日期计算阈值;"
|
|
|
- f"关停线为成本权重按P{int(weight_cap_quantile * 100)}封顶后的"
|
|
|
- f"消耗加权P{int(stop_quantile * 100)},"
|
|
|
- f"扩量线保持实体等权P{int(up_quantile * 100)};企微不参与。",
|
|
|
- ),
|
|
|
- (
|
|
|
- "阈值样本条件",
|
|
|
- f"连续2天分别满足:小程序单日首层UV>{self_min_uv:g},"
|
|
|
- f"公众号单日首层UV>{partner_min_uv:g},且当日成本>0、当日ROI有效。",
|
|
|
- ),
|
|
|
- (
|
|
|
- "动作口径",
|
|
|
- f"连续2天均处于同渠道当日消耗加权后"
|
|
|
- f"{int(stop_quantile * 100)}%才建议关停,"
|
|
|
- f"连续2天均处于实体等权前{int((1 - up_quantile) * 100)}%"
|
|
|
- "才建议扩量;"
|
|
|
- "方向不一致或广告age不足时观察。",
|
|
|
- ),
|
|
|
- (
|
|
|
- "观察补充",
|
|
|
- f"未满足连续2天正式样本条件,但最新日首层UV>{observe_min_uv:g}的实体也进入报表末尾;"
|
|
|
- "只展示最新日ROI,不进入阈值计算和腾讯执行。",
|
|
|
- ),
|
|
|
- ("审批方式", "在小程序投流表黄色【审批选择】列逐行选择批准或拒绝;批准即为最终确认并自动执行"),
|
|
|
+ ("数据深度口径", "usersharedepth<=1,与最新业务SQL一致"),
|
|
|
+ ("关停线(P20)", threshold.get("t_stop")),
|
|
|
+ ("阈值样本数", threshold.get("阈值样本数")),
|
|
|
+ ("创意扩量线(P80)", threshold.get("t_up")),
|
|
|
+ ("扩量样本数", threshold.get("扩量样本数")),
|
|
|
+ ("阈值样本", "连续三天每天首层UV>200、成本>0且ROI有效的小程序创意和公众号实体"),
|
|
|
+ ("广告级", "直接按广告去重计算,复用统一P20但不进入样本池;低于关停线且广告age达标时,审批后暂停整个广告"),
|
|
|
+ ("日均字段", "三日总量/3,缺失日按0"),
|
|
|
+ ("ROI与裂变率", "三日汇总分子/三日汇总分母的加权口径"),
|
|
|
+ ("补充观察", "非正式样本中最新日首层UV>200,置于汇总表末尾且不执行"),
|
|
|
+ ("配置快照", json.dumps(dict(config), ensure_ascii=False, default=str)),
|
|
|
]
|
|
|
- for label, value in rows:
|
|
|
- ws.append([label, value])
|
|
|
-
|
|
|
- if rule_config:
|
|
|
- ws.append([])
|
|
|
- ws.append(["规则配置", "值"])
|
|
|
- for key in (
|
|
|
- "self_stop_min_age",
|
|
|
- "self_up_min_age",
|
|
|
- "self_min_daily_uv",
|
|
|
- "partner_min_daily_uv",
|
|
|
- "observe_min_latest_uv",
|
|
|
- "stop_quantile",
|
|
|
- "up_quantile",
|
|
|
- "stop_weight_cap_quantile",
|
|
|
- "scale_ratio",
|
|
|
- "scale_cooldown_days",
|
|
|
- "max_base_ratio",
|
|
|
+ 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
|
|
|
):
|
|
|
- ws.append([key, rule_config.get(key)])
|
|
|
- fission_multiplier = rule_config.get("fission_multiplier")
|
|
|
- if isinstance(fission_multiplier, Mapping):
|
|
|
- ws.append([])
|
|
|
- ws.append(["传播裂变参数", "值"])
|
|
|
- for key in (
|
|
|
- "version",
|
|
|
- "cohort_date",
|
|
|
- "observation_end_date",
|
|
|
- "horizon_days",
|
|
|
- "miniapp_exact_available_rows",
|
|
|
- "gzh_exact_available_rows",
|
|
|
- "miniapp_channel_multiplier",
|
|
|
- "gzh_channel_multiplier",
|
|
|
- "qiwei_exact_available_rows",
|
|
|
- ):
|
|
|
- if key in fission_multiplier:
|
|
|
- ws.append([key, fission_multiplier[key]])
|
|
|
-
|
|
|
- ws.append([])
|
|
|
- display_thresholds = thresholds.rename(
|
|
|
- columns={"t_stop": "关停线", "t_up": "扩量线"}
|
|
|
- )
|
|
|
- threshold_columns = [
|
|
|
- "统计日期",
|
|
|
- "渠道",
|
|
|
- "关停线",
|
|
|
- "扩量线",
|
|
|
- "阈值样本数",
|
|
|
- "关停线口径",
|
|
|
- "权重封顶成本",
|
|
|
- "权重封顶分位",
|
|
|
- "扩量线口径",
|
|
|
- ]
|
|
|
- ws.append(threshold_columns)
|
|
|
- for values in display_thresholds[threshold_columns].itertuples(
|
|
|
- index=False,
|
|
|
- name=None,
|
|
|
- ):
|
|
|
- ws.append(list(values))
|
|
|
-
|
|
|
- ws.append([])
|
|
|
- ws.append(["渠道", "建议动作数"])
|
|
|
- for entity_type, sheet_name in ENTITY_TO_SHEET.items():
|
|
|
- count = int(
|
|
|
- (
|
|
|
- candidates["entity_type"].eq(entity_type)
|
|
|
- & candidates["动作"].ne("")
|
|
|
- ).sum()
|
|
|
- )
|
|
|
- ws.append([sheet_name, count])
|
|
|
-
|
|
|
- ws.column_dimensions["A"].width = 22
|
|
|
- ws.column_dimensions["B"].width = 92
|
|
|
- for row in ws.iter_rows():
|
|
|
- if row[0].value in {"统计窗口", "渠道"}:
|
|
|
- for cell in row:
|
|
|
- cell.font = Font(bold=True)
|
|
|
- ws.freeze_panes = "A2"
|
|
|
+ value_cell.number_format = "0" if label.endswith("数") else "0.00"
|
|
|
|
|
|
|
|
|
def write_workbook(
|
|
|
- candidates: pd.DataFrame,
|
|
|
+ rows: pd.DataFrame,
|
|
|
thresholds: pd.DataFrame,
|
|
|
expected_dates: Sequence[str],
|
|
|
output_path: Path,
|
|
|
- rule_config: Mapping[str, object] | None = None,
|
|
|
+ config: Mapping[str, object],
|
|
|
fission_match_summary: pd.DataFrame | None = None,
|
|
|
-) -> Path:
|
|
|
- output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
+) -> None:
|
|
|
workbook = Workbook()
|
|
|
workbook.remove(workbook.active)
|
|
|
|
|
|
- for sheet_name in BASE_COLUMNS:
|
|
|
+ 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)
|
|
|
- stop_quantile = float((rule_config or {}).get("stop_quantile", 0.25))
|
|
|
- visible_columns = _visible_columns(
|
|
|
- sheet_name,
|
|
|
- expected_dates,
|
|
|
- stop_quantile,
|
|
|
- )
|
|
|
- frame = _sheet_frame(
|
|
|
- candidates,
|
|
|
- sheet_name,
|
|
|
- expected_dates,
|
|
|
- stop_quantile,
|
|
|
+ _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, sheet_name, visible_columns)
|
|
|
+ _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)
|
|
|
- ws.freeze_panes = "A2"
|
|
|
- ws.auto_filter.ref = ws.dimensions
|
|
|
- for cell in ws[1]:
|
|
|
- cell.fill = HEADER_FILL
|
|
|
- cell.font = HEADER_FONT
|
|
|
- for column_index in range(1, ws.max_column + 1):
|
|
|
- header = str(ws.cell(1, column_index).value or "")
|
|
|
- values = [
|
|
|
- str(ws.cell(row, column_index).value or "")
|
|
|
- for row in range(1, ws.max_row + 1)
|
|
|
- ]
|
|
|
- width = min(max(max(map(len, values)), len(header)) + 2, 42)
|
|
|
- ws.column_dimensions[get_column_letter(column_index)].width = max(
|
|
|
- width, 11
|
|
|
- )
|
|
|
- if header == "匹配率":
|
|
|
- for row in range(2, ws.max_row + 1):
|
|
|
- ws.cell(row, column_index).number_format = "0.00%"
|
|
|
-
|
|
|
- _write_summary(
|
|
|
- workbook,
|
|
|
- thresholds,
|
|
|
- candidates,
|
|
|
- expected_dates,
|
|
|
- rule_config,
|
|
|
- )
|
|
|
+ _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)
|
|
|
- return output_path
|