baseline.py
Language Python6.8 KB156 lines
"""seed-baseline: load the roster workbook as per-customer baselines (one time)."""from __future__ import annotationsimport csvimport datetime as dtimport jsonimport reimport sysfrom pathlib import Pathfrom .history import _cust, to_float, load_history, save_historyfrom .paths import HISTORY# Fuzzy header → canonical field. Override any of these with --map "field=Header".BASELINE_FIELDS = {"user_id": ["id", "plinth id", "plinth_id", "customer id", "user id", "user_id", "customer_id"],"email": ["email", "email address", "primary email", "e-mail", "main email"],"additional_emails": ["additional emails", "additional_emails", "other emails","second email", "additional email", "additional email 1"],"first_name": ["first name", "first_name", "first"],"last_name": ["last name", "last_name", "last"],"company": ["company", "company name", "organization"],"phone": ["phone", "telephone", "phone number"],"ltv": ["ltv", "total spend", "total_spend", "lifetime value", "total spent","total historical spend", "total spent (orders)"],"orders_count": ["total orders", "orders", "order count", "total_orders_count","# of orders", "number of orders", "# orders"],"repeat_revenue": ["repeat revenue", "repeat_revenue", "repeat revenue (plinth)", "rr"],"first_order_date": ["first order date", "first order", "first_order_date"],"last_order_date": ["last order date", "last order", "last_order_date"],"user_group": ["user group", "user_group", "tier", "group"],}REQUIRED_BASELINE = ["user_id", "ltv"]def _match_headers(headers: list[str], overrides: dict) -> dict:"""Return {canonical: column_index} using overrides first, then fuzzy match."""norm = [re.sub(r"\s+", " ", (h or "").strip().lower()) for h in headers]mapping = {}for field, aliases in BASELINE_FIELDS.items():if field in overrides:want = overrides[field].strip().lower()for i, h in enumerate(norm):if h == want:mapping[field] = ibreakelse:sys.exit(f"--map {field}={overrides[field]!r}: no such column. Headers: {headers}")continuefor i, h in enumerate(norm):if h in aliases:mapping[field] = ibreakreturn mappingdef _read_sheet(path: Path, tab: str | None = None) -> list[list]:if path.suffix.lower() in (".xlsx", ".xlsm", ".xltx"):try:from openpyxl import load_workbookexcept ImportError:sys.exit("Reading xlsx needs openpyxl: pip install -r 07-Automation/requirements.txt")wb = load_workbook(path, read_only=True, data_only=True)if tab:if tab not in wb.sheetnames:sys.exit(f"No tab named {tab!r}. Tabs in this workbook: {wb.sheetnames}")ws = wb[tab]else:ws = wb.activeif len(wb.sheetnames) > 1:print(f"NOTE: multi-tab workbook, reading active tab {ws.title!r}. "f"Use --tab to pick another: {wb.sheetnames}")return [[c for c in row] for row in ws.iter_rows(values_only=True)]if tab:sys.exit("--tab only applies to xlsx workbooks.")with path.open(newline="", encoding="utf-8-sig") as f:return [row for row in csv.reader(f)]def cmd_seed_baseline(args):rows = _read_sheet(Path(args.sheet), args.tab)if not rows:sys.exit("Empty sheet.")overrides = dict(kv.split("=", 1) for kv in (args.map or []))headers = [str(h) if h is not None else "" for h in rows[0]]mapping = _match_headers(headers, overrides)missing = [f for f in REQUIRED_BASELINE if f not in mapping]print(f"Sheet: {args.sheet}: {len(rows) - 1} data rows")print("Detected column mapping:")for f, i in sorted(mapping.items()):print(f" {f:<18} <- column {i}: {headers[i]!r}")if missing:sys.exit(f"MISSING required column(s): {missing}. "f"Fix with --map \"{missing[0]}=<exact header>\". Headers: {headers}")as_of = args.as_ofif not as_of:sys.exit("--as-of YYYY-MM-DD is required: the date the sheet's totals run through ""(the roster's totals run 'up until Monday', so use that Monday). The weekly delta ""counts only orders AFTER this date, so it must be exact.")dt.date.fromisoformat(as_of) # validateif args.dry_run:print("\nDRY RUN: first 3 rows as parsed:")for row in rows[1:4]:parsed = {f: row[i] if i < len(row) else None for f, i in mapping.items()}print(" ", parsed)print("\nNothing written. Re-run without --dry-run to seed.")returnif HISTORY.exists() and not args.force:sys.exit(f"History already exists: {HISTORY}\n""seed-baseline is a one-time operation. Use --force to re-seed ""(this replaces baselines but keeps ingested orders).")h = load_history(required=False) if not HISTORY.exists() else json.loads(HISTORY.read_text(encoding="utf-8"))seeded = 0for row in rows[1:]:def val(f):i = mapping.get(f)return row[i] if i is not None and i < len(row) else Noneuid = val("user_id")if uid in (None, ""):continueuid = str(uid).strip().split(".")[0] # xlsx floats: 40115.0 -> "40115"c = _cust(h, uid, str(val("email") or ""))for f in ("email", "first_name", "last_name", "company", "phone"):v = val(f)if v not in (None, ""):c[f] = str(v).strip().lower() if f == "email" else str(v).strip()ug = val("user_group")if ug not in (None, ""):c["store_user_group"] = str(ug).strip()emails = [c["email"]] if c["email"] else []extra = val("additional_emails")if extra not in (None, ""):emails += [e.strip().lower() for e in re.split(r"[;,|]", str(extra)) if e.strip()]c["known_emails"] = sorted(set(emails + c.get("known_emails", [])))c["baseline"] = {"as_of": as_of,"ltv": round(to_float(val("ltv")), 2),"orders_count": int(to_float(val("orders_count"))),"repeat_revenue": round(to_float(val("repeat_revenue")), 2),"first_order_date": str(val("first_order_date") or "")[:10],"last_order_date": str(val("last_order_date") or "")[:10],"source": Path(args.sheet).name + (f" [{args.tab}]" if args.tab else ""),}seeded += 1h["meta"]["baseline_source"] = Path(args.sheet).nameh["meta"]["baseline_as_of"] = as_ofsave_history(h)print(f"\nSeeded baselines for {seeded} customers (as_of={as_of}) -> {HISTORY}")