OOB-Dashboard/ppcbudget/perfjoin.py

174 lines
5.9 KiB
Python

"""Optional join against a campaign performance report.
The change-history export carries no per-campaign spend, so dollar figures are
limited to the ~9% of campaigns whose budget happens to appear in a budget
change row. Dropping in any Amazon Ads campaign report that has Campaign,
Spend, Sales and Budget columns lifts that to full coverage.
Join coverage is reported in both directions -- a silent join failure is how
dashboards start lying.
"""
from __future__ import annotations
import csv
import re
from dataclasses import dataclass, field
from pathlib import Path
import openpyxl
from .scoring import CampaignDay
# Header aliases seen across Amazon Ads report variants.
ALIASES = {
"campaign": ["campaign", "campaign name", "campaigns"],
"spend": ["spend", "cost", "total spend"],
"sales": ["sales", "total sales", "14 day total sales", "7 day total sales",
"attributed sales", "total advertising cost of sales"],
"budget": ["budget", "daily budget", "campaign daily budget"],
"impressions": ["impressions", "impr"],
"clicks": ["clicks"],
"orders": ["orders", "total orders", "14 day total orders", "7 day total orders"],
"roas": ["roas", "total roas", "return on ad spend"],
}
@dataclass(slots=True)
class PerfRecord:
campaign_raw: str
spend: float | None = None
sales: float | None = None
budget: float | None = None
impressions: float | None = None
clicks: float | None = None
orders: float | None = None
roas: float | None = None
@dataclass(slots=True)
class JoinReport:
path: Path
rows_read: int = 0
matched: int = 0
unmatched_perf: list[str] = field(default_factory=list)
unmatched_history: list[str] = field(default_factory=list)
budgets_added: int = 0
roas_added: int = 0
@property
def coverage(self) -> float:
total = self.matched + len(self.unmatched_history)
return self.matched / total if total else 0.0
def campaign_key(name: str) -> str:
"""Normalize for joining: casefold, collapse whitespace, strip punctuation runs."""
return re.sub(r"\s+", " ", str(name).strip()).casefold()
def _num(value) -> float | None:
if value is None or value == "":
return None
text = re.sub(r"[^\d.\-]", "", str(value))
if text in ("", "-", ".", "-."):
return None
try:
return float(text)
except ValueError:
return None
def _map_headers(header: list) -> dict[str, int]:
lookup = {}
normalized = [re.sub(r"\s+", " ", str(h or "").strip().lower()) for h in header]
for field_name, names in ALIASES.items():
for i, h in enumerate(normalized):
if h in names:
lookup[field_name] = i
break
return lookup
def _read_rows(path: Path) -> tuple[list, list[list]]:
if path.suffix.lower() in (".csv", ".tsv", ".txt"):
delim = "\t" if path.suffix.lower() == ".tsv" else ","
with path.open(newline="", encoding="utf-8-sig") as fh:
rows = list(csv.reader(fh, delimiter=delim))
return (rows[0], rows[1:]) if rows else ([], [])
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
ws = wb[wb.sheetnames[0]]
rows = list(ws.iter_rows(values_only=True))
wb.close()
if not rows:
return [], []
# Some Amazon reports carry a title block before the real header.
for i, row in enumerate(rows[:10]):
if row and _map_headers(list(row)).get("campaign") is not None:
return list(row), [list(r) for r in rows[i + 1:]]
return list(rows[0]), [list(r) for r in rows[1:]]
def load_performance(path: str | Path) -> tuple[dict[str, PerfRecord], JoinReport]:
path = Path(path)
header, rows = _read_rows(path)
cols = _map_headers(header)
if "campaign" not in cols:
raise ValueError(
f"{path.name}: no Campaign column found. Expected one of: "
+ ", ".join(ALIASES['campaign'])
)
report = JoinReport(path=path)
records: dict[str, PerfRecord] = {}
def at(row, name):
i = cols.get(name)
return row[i] if i is not None and i < len(row) else None
for row in rows:
if not row or not at(row, "campaign"):
continue
raw = str(at(row, "campaign")).strip()
key = campaign_key(raw)
report.rows_read += 1
rec = records.get(key) or PerfRecord(campaign_raw=raw)
for f in ("spend", "sales", "budget", "impressions", "clicks", "orders", "roas"):
v = _num(at(row, f))
if v is not None:
prev = getattr(rec, f)
# Reports can be split by day/placement; sum the additive ones.
setattr(rec, f, v if prev is None or f in ("budget", "roas") else prev + v)
records[key] = rec
for rec in records.values():
if rec.roas is None and rec.spend and rec.sales is not None and rec.spend > 0:
rec.roas = rec.sales / rec.spend
return records, report
def apply_to(days: list[CampaignDay], records: dict[str, PerfRecord],
report: JoinReport) -> None:
"""Overlay observed budgets onto scored days; unmatched names are reported."""
seen: set[str] = set()
for day in days:
key = campaign_key(day.campaign)
rec = records.get(key)
if rec is None:
report.unmatched_history.append(day.campaign)
continue
seen.add(key)
report.matched += 1
if rec.budget is not None and rec.budget > 0:
if day.budget.source == "unknown":
report.budgets_added += 1
day.budget.value = rec.budget
day.budget.time_weighted = rec.budget
day.budget.source = "perf_report"
if rec.roas is not None:
report.roas_added += 1
day.perf = rec # type: ignore[attr-defined]
report.unmatched_perf = [r.campaign_raw for k, r in records.items() if k not in seen]