mattrguerra/meridian-outreach-vaultmainPrivate on GitHub, published here in full

baseline.py

Language Python6.8 KB156 lines

07-Automation/partner_pipeline/baseline.py

  1. """seed-baseline: load the roster workbook as per-customer baselines (one time)."""
  2. from __future__ import annotations
  3. import csv
  4. import datetime as dt
  5. import json
  6. import re
  7. import sys
  8. from pathlib import Path
  9. from .history import _cust, to_float, load_history, save_history
  10. from .paths import HISTORY
  11. # Fuzzy header → canonical field. Override any of these with --map "field=Header".
  12. BASELINE_FIELDS = {
  13. "user_id": ["id", "plinth id", "plinth_id", "customer id", "user id", "user_id", "customer_id"],
  14. "email": ["email", "email address", "primary email", "e-mail", "main email"],
  15. "additional_emails": ["additional emails", "additional_emails", "other emails",
  16. "second email", "additional email", "additional email 1"],
  17. "first_name": ["first name", "first_name", "first"],
  18. "last_name": ["last name", "last_name", "last"],
  19. "company": ["company", "company name", "organization"],
  20. "phone": ["phone", "telephone", "phone number"],
  21. "ltv": ["ltv", "total spend", "total_spend", "lifetime value", "total spent",
  22. "total historical spend", "total spent (orders)"],
  23. "orders_count": ["total orders", "orders", "order count", "total_orders_count",
  24. "# of orders", "number of orders", "# orders"],
  25. "repeat_revenue": ["repeat revenue", "repeat_revenue", "repeat revenue (plinth)", "rr"],
  26. "first_order_date": ["first order date", "first order", "first_order_date"],
  27. "last_order_date": ["last order date", "last order", "last_order_date"],
  28. "user_group": ["user group", "user_group", "tier", "group"],
  29. }
  30. REQUIRED_BASELINE = ["user_id", "ltv"]
  31. def _match_headers(headers: list[str], overrides: dict) -> dict:
  32. """Return {canonical: column_index} using overrides first, then fuzzy match."""
  33. norm = [re.sub(r"\s+", " ", (h or "").strip().lower()) for h in headers]
  34. mapping = {}
  35. for field, aliases in BASELINE_FIELDS.items():
  36. if field in overrides:
  37. want = overrides[field].strip().lower()
  38. for i, h in enumerate(norm):
  39. if h == want:
  40. mapping[field] = i
  41. break
  42. else:
  43. sys.exit(f"--map {field}={overrides[field]!r}: no such column. Headers: {headers}")
  44. continue
  45. for i, h in enumerate(norm):
  46. if h in aliases:
  47. mapping[field] = i
  48. break
  49. return mapping
  50. def _read_sheet(path: Path, tab: str | None = None) -> list[list]:
  51. if path.suffix.lower() in (".xlsx", ".xlsm", ".xltx"):
  52. try:
  53. from openpyxl import load_workbook
  54. except ImportError:
  55. sys.exit("Reading xlsx needs openpyxl: pip install -r 07-Automation/requirements.txt")
  56. wb = load_workbook(path, read_only=True, data_only=True)
  57. if tab:
  58. if tab not in wb.sheetnames:
  59. sys.exit(f"No tab named {tab!r}. Tabs in this workbook: {wb.sheetnames}")
  60. ws = wb[tab]
  61. else:
  62. ws = wb.active
  63. if len(wb.sheetnames) > 1:
  64. print(f"NOTE: multi-tab workbook, reading active tab {ws.title!r}. "
  65. f"Use --tab to pick another: {wb.sheetnames}")
  66. return [[c for c in row] for row in ws.iter_rows(values_only=True)]
  67. if tab:
  68. sys.exit("--tab only applies to xlsx workbooks.")
  69. with path.open(newline="", encoding="utf-8-sig") as f:
  70. return [row for row in csv.reader(f)]
  71. def cmd_seed_baseline(args):
  72. rows = _read_sheet(Path(args.sheet), args.tab)
  73. if not rows:
  74. sys.exit("Empty sheet.")
  75. overrides = dict(kv.split("=", 1) for kv in (args.map or []))
  76. headers = [str(h) if h is not None else "" for h in rows[0]]
  77. mapping = _match_headers(headers, overrides)
  78. missing = [f for f in REQUIRED_BASELINE if f not in mapping]
  79. print(f"Sheet: {args.sheet}: {len(rows) - 1} data rows")
  80. print("Detected column mapping:")
  81. for f, i in sorted(mapping.items()):
  82. print(f" {f:<18} <- column {i}: {headers[i]!r}")
  83. if missing:
  84. sys.exit(f"MISSING required column(s): {missing}. "
  85. f"Fix with --map \"{missing[0]}=<exact header>\". Headers: {headers}")
  86. as_of = args.as_of
  87. if not as_of:
  88. sys.exit("--as-of YYYY-MM-DD is required: the date the sheet's totals run through "
  89. "(the roster's totals run 'up until Monday', so use that Monday). The weekly delta "
  90. "counts only orders AFTER this date, so it must be exact.")
  91. dt.date.fromisoformat(as_of) # validate
  92. if args.dry_run:
  93. print("\nDRY RUN: first 3 rows as parsed:")
  94. for row in rows[1:4]:
  95. parsed = {f: row[i] if i < len(row) else None for f, i in mapping.items()}
  96. print(" ", parsed)
  97. print("\nNothing written. Re-run without --dry-run to seed.")
  98. return
  99. if HISTORY.exists() and not args.force:
  100. sys.exit(f"History already exists: {HISTORY}\n"
  101. "seed-baseline is a one-time operation. Use --force to re-seed "
  102. "(this replaces baselines but keeps ingested orders).")
  103. h = load_history(required=False) if not HISTORY.exists() else json.loads(
  104. HISTORY.read_text(encoding="utf-8"))
  105. seeded = 0
  106. for row in rows[1:]:
  107. def val(f):
  108. i = mapping.get(f)
  109. return row[i] if i is not None and i < len(row) else None
  110. uid = val("user_id")
  111. if uid in (None, ""):
  112. continue
  113. uid = str(uid).strip().split(".")[0] # xlsx floats: 40115.0 -> "40115"
  114. c = _cust(h, uid, str(val("email") or ""))
  115. for f in ("email", "first_name", "last_name", "company", "phone"):
  116. v = val(f)
  117. if v not in (None, ""):
  118. c[f] = str(v).strip().lower() if f == "email" else str(v).strip()
  119. ug = val("user_group")
  120. if ug not in (None, ""):
  121. c["store_user_group"] = str(ug).strip()
  122. emails = [c["email"]] if c["email"] else []
  123. extra = val("additional_emails")
  124. if extra not in (None, ""):
  125. emails += [e.strip().lower() for e in re.split(r"[;,|]", str(extra)) if e.strip()]
  126. c["known_emails"] = sorted(set(emails + c.get("known_emails", [])))
  127. c["baseline"] = {
  128. "as_of": as_of,
  129. "ltv": round(to_float(val("ltv")), 2),
  130. "orders_count": int(to_float(val("orders_count"))),
  131. "repeat_revenue": round(to_float(val("repeat_revenue")), 2),
  132. "first_order_date": str(val("first_order_date") or "")[:10],
  133. "last_order_date": str(val("last_order_date") or "")[:10],
  134. "source": Path(args.sheet).name + (f" [{args.tab}]" if args.tab else ""),
  135. }
  136. seeded += 1
  137. h["meta"]["baseline_source"] = Path(args.sheet).name
  138. h["meta"]["baseline_as_of"] = as_of
  139. save_history(h)
  140. print(f"\nSeeded baselines for {seeded} customers (as_of={as_of}) -> {HISTORY}")