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

roster_workbook.py

Language Python8.4 KB202 lines

07-Automation/partner_pipeline/roster_workbook.py

  1. """refresh-workbook: regenerate the Drive-swap roster workbook.
  2. The vault copy of the roster is not connected to Google Drive (it is swapped in by
  3. hand), so each run carries the human-maintained columns forward from the previous
  4. generated copy (the base), refreshes only the Plinth-derived columns, appends newly
  5. qualified partner clients, rebuilds the all-customer tab, and leaves the curated
  6. working tab untouched. A human-maintained column on an existing row is never written.
  7. """
  8. from __future__ import annotations
  9. import datetime as dt
  10. import json
  11. import sys
  12. from pathlib import Path
  13. from .config import NON_PARTNER, guard_or_exit
  14. from .history import load_merge_registry
  15. from .paths import OUT_DIR, TODAY
  16. PARTNER_TAB = "Complete Partner Clients List"
  17. ALL_TAB = "All Accounts"
  18. def latest_updated_workbook(exclude: Path | None = None):
  19. cands = sorted(OUT_DIR.glob("Complete-Partner-Clients-List_updated-*.xlsx"))
  20. cands = [c for c in cands if exclude is None or c.resolve() != exclude.resolve()]
  21. return cands[-1] if cands else None
  22. def _as_date(s):
  23. try:
  24. return dt.date.fromisoformat(str(s)[:10])
  25. except Exception:
  26. return s or ""
  27. def cmd_refresh_workbook(args):
  28. guard_or_exit()
  29. from openpyxl import load_workbook
  30. from copy import copy as _copy
  31. def _snap(cell):
  32. # snapshot a cell's full visual style (survives row deletion)
  33. return (_copy(cell.font), _copy(cell.fill), _copy(cell.border),
  34. _copy(cell.alignment), cell.number_format, _copy(cell.protection))
  35. def _apply(cell, snap):
  36. cell.font, cell.fill, cell.border, cell.alignment = snap[0], snap[1], snap[2], snap[3]
  37. cell.number_format = snap[4]
  38. cell.protection = snap[5]
  39. plan = json.loads(Path(args.plan).read_text(encoding="utf-8"))
  40. rows = plan["rows"]
  41. by_uid = {str(r["user_id"]): r for r in rows if r.get("user_id") not in (None, "")}
  42. partner = [r for r in rows if r["assigned_tier"] not in NON_PARTNER]
  43. absorbed_uids = set(load_merge_registry()[0])
  44. stale_merged = []
  45. out = OUT_DIR / f"Complete-Partner-Clients-List_updated-{TODAY}.xlsx"
  46. base = Path(args.base) if args.base else latest_updated_workbook(exclude=out)
  47. if not base or not base.exists():
  48. sys.exit("No base workbook found (Complete-Partner-Clients-List_updated-*.xlsx in "
  49. f"{OUT_DIR}); pass --base, or generate the first copy by hand.")
  50. wb = load_workbook(base)
  51. d = dt.date.fromisoformat(TODAY)
  52. date_us = f"{d.month:02d}/{d.day:02d}/{str(d.year)[2:]}"
  53. # Partner tab: refresh the Plinth columns, append newly qualified partners.
  54. ws = wb[PARTNER_TAB]
  55. hdr = [c.value for c in ws[1]]
  56. def col(name, prefix=False):
  57. for idx, h in enumerate(hdr):
  58. if h is None:
  59. continue
  60. if (prefix and str(h).startswith(name)) or (not prefix and h == name):
  61. return idx
  62. return None
  63. c_id = col("Id")
  64. c_fn = col("First Name")
  65. c_ln = col("Last Name")
  66. c_em = col("Email")
  67. c_co = col("Company")
  68. c_ph = col("Phone")
  69. c_ug = col("User Group")
  70. c_cg = col("Correct Group")
  71. c_ltvd = col("Plinth LTV", prefix=True) # dated lifetime column
  72. c_tot = col("Total Spent (Orders)")
  73. c_last = col("Last Order")
  74. c_ord = col("# Orders")
  75. c_rr = col("Repeat Revenue (Plinth)")
  76. c_first = col("First Order")
  77. if c_ltvd is not None: # rename dated header to this run
  78. ws.cell(row=1, column=c_ltvd + 1).value = f"Plinth LTV {date_us}"
  79. def _uid_of(v):
  80. if v is None:
  81. return None
  82. if isinstance(v, float) and v.is_integer():
  83. return str(int(v))
  84. return str(v).strip()
  85. # Per-column master style from an existing data row (row 2). row 2's Plinth cells are
  86. # always populated and correctly formatted (currency, mm/dd/yyyy, Arial), so we clone
  87. # it onto every Plinth cell we write (existing rows and appended rows), so formatting
  88. # always matches the master, even on rows a prior run left unformatted. Plinth columns
  89. # only on existing rows; human columns are never restyled and never revalued.
  90. tmpl = [_snap(ws.cell(row=2, column=ci + 1)) for ci in range(len(hdr))] if ws.max_row >= 2 else None
  91. plinth_cols = [c for c in (c_ug, c_ltvd, c_tot, c_last, c_ord, c_rr, c_first) if c is not None]
  92. seen, refreshed = set(), 0
  93. for row in ws.iter_rows(min_row=2):
  94. uid = _uid_of(row[c_id].value)
  95. if uid is None:
  96. continue
  97. seen.add(uid)
  98. if uid in absorbed_uids:
  99. stale_merged.append(uid) # merged-away account: leave the row alone,
  100. continue # flag below for a human to strike by hand
  101. r = by_uid.get(uid)
  102. if not r:
  103. continue
  104. # Only Plinth-derived columns. Identity and all human columns are left untouched.
  105. if c_ug is not None: row[c_ug].value = r.get("store_user_group") or ""
  106. if c_ltvd is not None: row[c_ltvd].value = round(r["ltv"], 2)
  107. if c_tot is not None: row[c_tot].value = round(r["ltv"], 2)
  108. if c_last is not None: row[c_last].value = _as_date(r.get("last_order_date"))
  109. if c_ord is not None: row[c_ord].value = r.get("total_orders_count") or 0
  110. if c_rr is not None: row[c_rr].value = round(r.get("repeat_revenue", 0), 2)
  111. if c_first is not None: row[c_first].value = _as_date(r.get("first_order_date"))
  112. if tmpl: # normalize Plinth-cell formatting to the master
  113. for ci in plinth_cols:
  114. _apply(row[ci], tmpl[ci])
  115. refreshed += 1
  116. ncols, appended = len(hdr), 0
  117. for r in partner:
  118. uid = _uid_of(r.get("user_id"))
  119. if uid is None or uid in seen:
  120. continue
  121. nr = [None] * ncols
  122. def setc(ci, val):
  123. if ci is not None:
  124. nr[ci] = val
  125. setc(c_id, int(uid) if uid.isdigit() else uid)
  126. setc(c_fn, r.get("first_name") or "")
  127. setc(c_ln, r.get("last_name") or "")
  128. setc(c_em, r.get("email") or "")
  129. setc(c_co, r.get("company") or "")
  130. setc(c_ph, r.get("phone") or "")
  131. setc(c_ug, r.get("store_user_group") or "")
  132. setc(c_cg, r.get("assigned_tier") or "") # new row: record computed tier (no human value to preserve)
  133. setc(c_ltvd, round(r["ltv"], 2))
  134. setc(c_tot, round(r["ltv"], 2))
  135. setc(c_last, _as_date(r.get("last_order_date")))
  136. setc(c_first, _as_date(r.get("first_order_date")))
  137. setc(c_ord, r.get("total_orders_count") or 0)
  138. setc(c_rr, round(r.get("repeat_revenue", 0), 2))
  139. ws.append(nr)
  140. rr = ws.max_row
  141. if tmpl:
  142. for ci in range(ncols):
  143. _apply(ws.cell(row=rr, column=ci + 1), tmpl[ci])
  144. seen.add(uid)
  145. appended += 1
  146. # All Accounts: rebuild the all-customer base.
  147. base_n = 0
  148. if ALL_TAB in wb.sheetnames:
  149. b = wb[ALL_TAB]
  150. bcols = b.max_column
  151. # snapshot the master row style per column before deleting (currency on LTV/RR etc.)
  152. btmpl = [_snap(b.cell(row=2, column=c)) for c in range(1, bcols + 1)] if b.max_row >= 2 else None
  153. if b.max_row > 1:
  154. b.delete_rows(2, b.max_row - 1)
  155. for r in rows:
  156. uid = _uid_of(r.get("user_id"))
  157. b.append([
  158. int(uid) if (uid and uid.isdigit()) else (uid or ""),
  159. r.get("first_name") or "", r.get("last_name") or "", r.get("email") or "",
  160. round(r["ltv"], 2), round(r.get("repeat_revenue", 0), 2),
  161. ])
  162. base_n += 1
  163. if btmpl: # re-apply the master per-column formatting to every rebuilt row
  164. for rr in range(2, b.max_row + 1):
  165. for c in range(1, bcols + 1):
  166. _apply(b.cell(row=rr, column=c), btmpl[c - 1])
  167. wb.save(out)
  168. if stale_merged:
  169. print(f"MERGE REGISTRY NOTE: partner-tab row(s) for absorbed account(s) "
  170. f"{', '.join(stale_merged)} were left untouched: strike them by hand "
  171. "(their spend now lives on the survivor's row).")
  172. print(f"Workbook refreshed: base {base.name} -> {out.name}. "
  173. f"Partner tab: {refreshed} rows refreshed, {appended} appended. "
  174. f"All Accounts rebuilt to {base_n} customers. Dated col renamed 'Plinth LTV {date_us}'. "
  175. f"Working tab and all human columns untouched.")
  176. print("REMINDER: append one LEDGER entry for this change-set.")