|
@@ -22,17 +22,17 @@ logger = logging.getLogger(__name__)
|
|
|
_MINI_DIR = Path(__file__).resolve().parent.parent
|
|
_MINI_DIR = Path(__file__).resolve().parent.parent
|
|
|
_REPORTS_DIR = _MINI_DIR / "outputs" / "reports"
|
|
_REPORTS_DIR = _MINI_DIR / "outputs" / "reports"
|
|
|
|
|
|
|
|
-# 最终输出列顺序
|
|
|
|
|
|
|
+# 最终输出列顺序(审批表格:简洁版,去掉技术性列)
|
|
|
OUTPUT_COLUMNS = [
|
|
OUTPUT_COLUMNS = [
|
|
|
- "ad_id", "account_id", "ad_name", "audience_tier",
|
|
|
|
|
- "create_time", "ad_age_days", "configured_status", "bid_amount",
|
|
|
|
|
|
|
+ # 核心标识(优先显示)
|
|
|
|
|
+ "account_id", "ad_id", "cost_7d_avg",
|
|
|
|
|
+ # 基础信息
|
|
|
|
|
+ "ad_name", "audience_tier", "create_time", "ad_age_days", "bid_amount",
|
|
|
# 昨日表现
|
|
# 昨日表现
|
|
|
"yesterday_cost", "yesterday_revenue", "yesterday_roi",
|
|
"yesterday_cost", "yesterday_revenue", "yesterday_roi",
|
|
|
# 7日汇总
|
|
# 7日汇总
|
|
|
- "cost_7d_total", "cost_7d_avg", "revenue_7d_total",
|
|
|
|
|
- # f_7日动态ROI 组成
|
|
|
|
|
- "T0裂变系数_latest", "arpu_latest", "a_latest",
|
|
|
|
|
- "b_7d_mean", "T0裂变系数_7d_mean", "e_factor",
|
|
|
|
|
|
|
+ "cost_7d_total", "revenue_7d_total",
|
|
|
|
|
+ # f_7日动态ROI(仅结果值,不显示组成)
|
|
|
"f_7日动态ROI",
|
|
"f_7日动态ROI",
|
|
|
# 30日上下文
|
|
# 30日上下文
|
|
|
"cost_30d_total", "cost_30d_avg",
|
|
"cost_30d_total", "cost_30d_avg",
|
|
@@ -40,17 +40,16 @@ OUTPUT_COLUMNS = [
|
|
|
# 决策
|
|
# 决策
|
|
|
"action", "dimension", "reason",
|
|
"action", "dimension", "reason",
|
|
|
"recommended_change_pct", "current_bid", "recommended_bid",
|
|
"recommended_change_pct", "current_bid", "recommended_bid",
|
|
|
- # 护栏 & 执行
|
|
|
|
|
- "guardrail_status", "guardrail_reason", "final_action", "final_bid",
|
|
|
|
|
- "execution_status",
|
|
|
|
|
# 参考
|
|
# 参考
|
|
|
"f_7日动态ROI_mean_all",
|
|
"f_7日动态ROI_mean_all",
|
|
|
]
|
|
]
|
|
|
|
|
|
|
|
# 中文列名映射
|
|
# 中文列名映射
|
|
|
CN_COLUMNS = {
|
|
CN_COLUMNS = {
|
|
|
- "ad_id": "广告ID",
|
|
|
|
|
|
|
+ "approval_date": "日期",
|
|
|
"account_id": "账户ID",
|
|
"account_id": "账户ID",
|
|
|
|
|
+ "ad_id": "广告ID",
|
|
|
|
|
+ "cost_7d_avg": "广告消耗(7日日均/元)",
|
|
|
"ad_name": "广告名称",
|
|
"ad_name": "广告名称",
|
|
|
"audience_tier": "人群包",
|
|
"audience_tier": "人群包",
|
|
|
"create_time": "创建时间",
|
|
"create_time": "创建时间",
|
|
@@ -61,7 +60,6 @@ CN_COLUMNS = {
|
|
|
"yesterday_revenue": "昨日收入(元)",
|
|
"yesterday_revenue": "昨日收入(元)",
|
|
|
"yesterday_roi": "昨日ROI",
|
|
"yesterday_roi": "昨日ROI",
|
|
|
"cost_7d_total": "7日总消耗(元)",
|
|
"cost_7d_total": "7日总消耗(元)",
|
|
|
- "cost_7d_avg": "7日日均消耗(元)",
|
|
|
|
|
"revenue_7d_total": "7日总收入(元)",
|
|
"revenue_7d_total": "7日总收入(元)",
|
|
|
"T0裂变系数_latest": "T0裂变系数(最新)",
|
|
"T0裂变系数_latest": "T0裂变系数(最新)",
|
|
|
"arpu_latest": "ARPU(最新)",
|
|
"arpu_latest": "ARPU(最新)",
|
|
@@ -69,7 +67,8 @@ CN_COLUMNS = {
|
|
|
"b_7d_mean": "b值(7日均值)",
|
|
"b_7d_mean": "b值(7日均值)",
|
|
|
"T0裂变系数_7d_mean": "T0裂变系数(7日均值)",
|
|
"T0裂变系数_7d_mean": "T0裂变系数(7日均值)",
|
|
|
"e_factor": "e因子",
|
|
"e_factor": "e因子",
|
|
|
- "f_7日动态ROI": "f_7日动态ROI",
|
|
|
|
|
|
|
+ "f_7日动态ROI": "7日均值动态ROI",
|
|
|
|
|
+ "动态ROI_7日均值": "7日均值动态ROI",
|
|
|
"cost_30d_total": "30日总消耗(元)",
|
|
"cost_30d_total": "30日总消耗(元)",
|
|
|
"cost_30d_avg": "30日日均消耗(元)",
|
|
"cost_30d_avg": "30日日均消耗(元)",
|
|
|
"stable_spend_days_30d": "稳定消耗天数(30日)",
|
|
"stable_spend_days_30d": "稳定消耗天数(30日)",
|
|
@@ -80,12 +79,18 @@ CN_COLUMNS = {
|
|
|
"recommended_change_pct": "建议调幅(%)",
|
|
"recommended_change_pct": "建议调幅(%)",
|
|
|
"current_bid": "当前出价(元)",
|
|
"current_bid": "当前出价(元)",
|
|
|
"recommended_bid": "建议出价(元)",
|
|
"recommended_bid": "建议出价(元)",
|
|
|
- "guardrail_status": "护栏状态",
|
|
|
|
|
"guardrail_reason": "护栏说明",
|
|
"guardrail_reason": "护栏说明",
|
|
|
- "final_action": "最终动作",
|
|
|
|
|
- "final_bid": "最终出价(元)",
|
|
|
|
|
"execution_status": "执行状态",
|
|
"execution_status": "执行状态",
|
|
|
"f_7日动态ROI_mean_all": "全体动态ROI均值",
|
|
"f_7日动态ROI_mean_all": "全体动态ROI均值",
|
|
|
|
|
+ "source": "数据来源",
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+# 动作中文映射
|
|
|
|
|
+ACTION_CN_MAP = {
|
|
|
|
|
+ "pause": "关停",
|
|
|
|
|
+ "bid_down": "降价",
|
|
|
|
|
+ "bid_up": "提价",
|
|
|
|
|
+ "hold": "保持",
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@@ -93,12 +98,16 @@ def _write_xlsx_with_format(df: pd.DataFrame, path: Path) -> None:
|
|
|
"""生成带条件格式的 XLSX 文件。"""
|
|
"""生成带条件格式的 XLSX 文件。"""
|
|
|
try:
|
|
try:
|
|
|
import openpyxl
|
|
import openpyxl
|
|
|
- from openpyxl.styles import Font, PatternFill, Alignment
|
|
|
|
|
|
|
+ from openpyxl.styles import Font, Alignment, PatternFill
|
|
|
from openpyxl.utils import get_column_letter
|
|
from openpyxl.utils import get_column_letter
|
|
|
except ImportError:
|
|
except ImportError:
|
|
|
logger.warning("openpyxl 未安装,跳过 XLSX 生成")
|
|
logger.warning("openpyxl 未安装,跳过 XLSX 生成")
|
|
|
return
|
|
return
|
|
|
|
|
|
|
|
|
|
+ # 动作中文化
|
|
|
|
|
+ if "action" in df.columns:
|
|
|
|
|
+ df["action"] = df["action"].map(ACTION_CN_MAP).fillna(df["action"])
|
|
|
|
|
+
|
|
|
# 中文列名
|
|
# 中文列名
|
|
|
df_cn = df.rename(columns=CN_COLUMNS)
|
|
df_cn = df.rename(columns=CN_COLUMNS)
|
|
|
df_cn.to_excel(path, index=False, engine="openpyxl")
|
|
df_cn.to_excel(path, index=False, engine="openpyxl")
|
|
@@ -106,53 +115,69 @@ def _write_xlsx_with_format(df: pd.DataFrame, path: Path) -> None:
|
|
|
wb = openpyxl.load_workbook(path)
|
|
wb = openpyxl.load_workbook(path)
|
|
|
ws = wb.active
|
|
ws = wb.active
|
|
|
|
|
|
|
|
- # 表头样式
|
|
|
|
|
- header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
|
|
|
|
|
- header_font = Font(color="FFFFFF", bold=True, size=10)
|
|
|
|
|
|
|
+ # 表头样式(加粗 + 灰色背景)
|
|
|
|
|
+ header_font = Font(bold=True, size=10)
|
|
|
|
|
+ header_fill = PatternFill(start_color="D3D3D3", end_color="D3D3D3", fill_type="solid") # 灰色背景
|
|
|
|
|
+ center_alignment = Alignment(horizontal="center", vertical="center")
|
|
|
|
|
|
|
|
for cell in ws[1]:
|
|
for cell in ws[1]:
|
|
|
- cell.fill = header_fill
|
|
|
|
|
cell.font = header_font
|
|
cell.font = header_font
|
|
|
- cell.alignment = Alignment(horizontal="center")
|
|
|
|
|
|
|
+ cell.fill = header_fill
|
|
|
|
|
+ cell.alignment = center_alignment
|
|
|
|
|
+
|
|
|
|
|
+ # 所有数据单元格居中对齐
|
|
|
|
|
+ for row in ws.iter_rows(min_row=2, max_row=ws.max_row):
|
|
|
|
|
+ for cell in row:
|
|
|
|
|
+ cell.alignment = center_alignment
|
|
|
|
|
|
|
|
- # 条件格式:不同动作不同颜色
|
|
|
|
|
- red_fill = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid")
|
|
|
|
|
- red_font = Font(color="9C0006")
|
|
|
|
|
- yellow_fill = PatternFill(start_color="FFEB9C", end_color="FFEB9C", fill_type="solid")
|
|
|
|
|
- yellow_font = Font(color="9C6500")
|
|
|
|
|
- green_fill = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid")
|
|
|
|
|
- green_font = Font(color="006100")
|
|
|
|
|
|
|
+ # 自动列宽(增加30%)
|
|
|
|
|
+ for col_idx in range(1, ws.max_column + 1):
|
|
|
|
|
+ col_letter = get_column_letter(col_idx)
|
|
|
|
|
+ max_len = max(
|
|
|
|
|
+ len(str(ws.cell(row=r, column=col_idx).value or ""))
|
|
|
|
|
+ for r in range(1, min(ws.max_row + 1, 50))
|
|
|
|
|
+ )
|
|
|
|
|
+ # 列宽增加30%:原公式 max_len + 4,现改为 (max_len + 4) * 1.3
|
|
|
|
|
+ ws.column_dimensions[col_letter].width = min((max_len + 4) * 1.3, 40)
|
|
|
|
|
|
|
|
|
|
+ # 决策动作列条件格式化
|
|
|
action_col_idx = None
|
|
action_col_idx = None
|
|
|
for idx, cell in enumerate(ws[1], 1):
|
|
for idx, cell in enumerate(ws[1], 1):
|
|
|
if cell.value == "决策动作":
|
|
if cell.value == "决策动作":
|
|
|
action_col_idx = idx
|
|
action_col_idx = idx
|
|
|
|
|
+ # E1 单元格(决策动作表头)黄色高亮
|
|
|
|
|
+ yellow_header_fill = PatternFill(start_color="FFFF00", end_color="FFFF00", fill_type="solid")
|
|
|
|
|
+ cell.fill = yellow_header_fill
|
|
|
break
|
|
break
|
|
|
|
|
|
|
|
if action_col_idx:
|
|
if action_col_idx:
|
|
|
- for row in ws.iter_rows(min_row=2, max_row=ws.max_row):
|
|
|
|
|
- action_val = row[action_col_idx - 1].value
|
|
|
|
|
- if action_val == "pause":
|
|
|
|
|
- for cell in row:
|
|
|
|
|
- cell.fill = red_fill
|
|
|
|
|
- cell.font = red_font
|
|
|
|
|
- elif action_val == "bid_down":
|
|
|
|
|
- for cell in row:
|
|
|
|
|
- cell.fill = yellow_fill
|
|
|
|
|
- cell.font = yellow_font
|
|
|
|
|
- elif action_val == "bid_up":
|
|
|
|
|
- for cell in row:
|
|
|
|
|
- cell.fill = green_fill
|
|
|
|
|
- cell.font = green_font
|
|
|
|
|
-
|
|
|
|
|
- # 自动列宽
|
|
|
|
|
- for col_idx in range(1, ws.max_column + 1):
|
|
|
|
|
- col_letter = get_column_letter(col_idx)
|
|
|
|
|
- max_len = max(
|
|
|
|
|
- len(str(ws.cell(row=r, column=col_idx).value or ""))
|
|
|
|
|
- for r in range(1, min(ws.max_row + 1, 50))
|
|
|
|
|
- )
|
|
|
|
|
- ws.column_dimensions[col_letter].width = min(max_len + 4, 30)
|
|
|
|
|
|
|
+ # 颜色定义
|
|
|
|
|
+ yellow_fill = PatternFill(start_color="FFFF00", end_color="FFFF00", fill_type="solid") # 黄色(整列默认)
|
|
|
|
|
+ green_fill = PatternFill(start_color="90EE90", end_color="90EE90", fill_type="solid") # 浅绿色
|
|
|
|
|
+ red_fill = PatternFill(start_color="FF6B6B", end_color="FF6B6B", fill_type="solid") # 红色
|
|
|
|
|
+ orange_fill = PatternFill(start_color="FFB84D", end_color="FFB84D", fill_type="solid") # 橘黄色
|
|
|
|
|
+
|
|
|
|
|
+ # 为决策动作列的所有数据单元格设置颜色
|
|
|
|
|
+ for row_idx in range(2, ws.max_row + 1):
|
|
|
|
|
+ cell = ws.cell(row=row_idx, column=action_col_idx)
|
|
|
|
|
+ value = str(cell.value).strip() if cell.value else ""
|
|
|
|
|
+
|
|
|
|
|
+ # 根据值设置颜色
|
|
|
|
|
+ if value == "保持":
|
|
|
|
|
+ cell.fill = green_fill
|
|
|
|
|
+ elif value == "关停":
|
|
|
|
|
+ cell.fill = red_fill
|
|
|
|
|
+ elif value == "降价":
|
|
|
|
|
+ cell.fill = orange_fill
|
|
|
|
|
+ else:
|
|
|
|
|
+ # 其他值(如"提价")使用黄色
|
|
|
|
|
+ cell.fill = yellow_fill
|
|
|
|
|
+
|
|
|
|
|
+ # 启用自动筛选(首行)
|
|
|
|
|
+ ws.auto_filter.ref = ws.dimensions
|
|
|
|
|
+
|
|
|
|
|
+ # 冻结窗格:锁定第一行和前五列(冻结到F2单元格)
|
|
|
|
|
+ ws.freeze_panes = "F2"
|
|
|
|
|
|
|
|
wb.save(path)
|
|
wb.save(path)
|
|
|
logger.info("XLSX 已生成: %s", path)
|
|
logger.info("XLSX 已生成: %s", path)
|
|
@@ -192,21 +217,22 @@ async def generate_report(
|
|
|
cols = [c for c in OUTPUT_COLUMNS if c in df.columns]
|
|
cols = [c for c in OUTPUT_COLUMNS if c in df.columns]
|
|
|
df_out = df[cols].copy()
|
|
df_out = df[cols].copy()
|
|
|
|
|
|
|
|
- # 排序:关停在前,按消耗降序
|
|
|
|
|
|
|
+ # 排序:有消耗的在前,无消耗的在后,同组内按消耗降序
|
|
|
sort_cols = []
|
|
sort_cols = []
|
|
|
ascending_flags = []
|
|
ascending_flags = []
|
|
|
- if "action" in df_out.columns:
|
|
|
|
|
- df_out["_sort_action"] = (df_out["action"] == "pause").astype(int) * -1
|
|
|
|
|
- sort_cols.append("_sort_action")
|
|
|
|
|
- ascending_flags.append(True)
|
|
|
|
|
|
|
+ if "cost_7d_avg" in df_out.columns:
|
|
|
|
|
+ # 无消耗(cost_7d_avg=0)放最后
|
|
|
|
|
+ df_out["_has_spend"] = (df_out["cost_7d_avg"] > 0).astype(int)
|
|
|
|
|
+ sort_cols.append("_has_spend")
|
|
|
|
|
+ ascending_flags.append(False) # 1在前,0在后
|
|
|
if "cost_7d_total" in df_out.columns:
|
|
if "cost_7d_total" in df_out.columns:
|
|
|
sort_cols.append("cost_7d_total")
|
|
sort_cols.append("cost_7d_total")
|
|
|
ascending_flags.append(False)
|
|
ascending_flags.append(False)
|
|
|
|
|
|
|
|
if sort_cols:
|
|
if sort_cols:
|
|
|
df_out = df_out.sort_values(sort_cols, ascending=ascending_flags)
|
|
df_out = df_out.sort_values(sort_cols, ascending=ascending_flags)
|
|
|
- if "_sort_action" in df_out.columns:
|
|
|
|
|
- df_out.drop(columns=["_sort_action"], inplace=True)
|
|
|
|
|
|
|
+ if "_has_spend" in df_out.columns:
|
|
|
|
|
+ df_out.drop(columns=["_has_spend"], inplace=True)
|
|
|
|
|
|
|
|
# CSV
|
|
# CSV
|
|
|
csv_path = _REPORTS_DIR / f"decision_{end_date}.csv"
|
|
csv_path = _REPORTS_DIR / f"decision_{end_date}.csv"
|