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

generate_sample.py

Language Python37.9 KB679 lines

07-Automation/sample/generate_sample.py

  1. #!/usr/bin/env python3
  2. """
  3. generate_sample.py: build the invented client book this vault ships with.
  4. Meridian Display Co. is fictional, and so is every client in this vault. This
  5. script invents them: 381 Plinth accounts on the roster (plus one new customer
  6. who first orders during the sample weeks), two weeks of Plinth `orders.fetch`
  7. page dumps, and two weekly Bitrix deal enumerations with their stage histories.
  8. It is seeded, so every run produces the same world.
  9. `run_sample.py` then replays two Monday runs of the weekly pipeline against
  10. these inputs, and everything under 03-Clients/, 05-Outputs/partner-runs/ and
  11. 07-Automation/data/ is the pipeline's own output, not hand-written.
  12. The world is built to exercise the parts of the driver that matter:
  13. * a Plinth day that truncates silently, paged with overlapping offsets, so
  14. the ingest convergence loop has to keep going until a page adds nothing;
  15. * cancelled (27) and refunded (28) orders that must not count;
  16. * clients who cross a tier band mid-week, and a brand-new customer whose
  17. first order qualifies them (has_baseline=False);
  18. * an approved merge (one person, two Plinth accounts);
  19. * a directly-partnered account whose mailboxes are Rhodium by override and
  20. never cold-emailed, plus a staff mailbox on the company domain;
  21. * an identity twin (same email, two accounts) where only one twin carries
  22. the Not Interested closure;
  23. * a shared company mailbox used by two different people;
  24. * a role-named account and a messy shipping-label name;
  25. * retired Bitrix stages, same-minute workflow re-entries, and a cart that
  26. moved into production funnels after converting.
  27. Usage:
  28. python3 07-Automation/sample/generate_sample.py # writes sample/inputs/
  29. python3 07-Automation/sample/generate_sample.py --out DIR
  30. Needs openpyxl (the roster workbook is an .xlsx, like the real one).
  31. """
  32. from __future__ import annotations
  33. import argparse
  34. import datetime as dt
  35. import json
  36. import random
  37. from pathlib import Path
  38. SEED = 20260921
  39. HERE = Path(__file__).resolve().parent
  40. # Two Monday runs. The roster workbook's totals run through BASELINE_AS_OF.
  41. BASELINE_AS_OF = dt.date(2026, 9, 7)
  42. RUN_DATES = [dt.date(2026, 9, 14), dt.date(2026, 9, 21)]
  43. PULL_WINDOW_DAYS = 8 # matches partner-thresholds.md (one day of overlap)
  44. TRUNCATE_AT = 50 # Plinth's silent cap on this sample's busy day
  45. BUSY_DAY = dt.date(2026, 9, 17)
  46. # Id ranges. Everything is invented; the ranges only keep the kinds apart.
  47. ACCOUNT_BASE = 40000
  48. ORDER_BASE = 61000
  49. DEAL_BASE = 400100
  50. CONTACT_BASE = 600000
  51. TEST_DEALS = ["400017", "400023"] # must match TEST_DEALS in partner_pipeline/stages.py
  52. BANDS = [("Palladium Partner", 2500, 8000), ("Platinum Partner", 8000, 25000),
  53. ("Iridium Partner", 25000, 75000), ("Rhodium Partner", 75000, 160000)]
  54. FIRST = """Ada Aiden Alma Amara Anika Ansel Arlo Isla Audra Avery Beatrix Bennett Bram
  55. Briony Callum Calla Cassius Celia Cyrus Dalia Dario Delphine Denver Edie Elio Elowen
  56. Emeric Esme Ezra Fallon Felix Fenna Florian Gemma Greer Hale Harriet Idris Ilse
  57. Imogen Isaak Jonah Juniper Kai Kasimir Keira Lark Leander Lena Linus Lucia Mabel
  58. Magnus Maren Matteo Mira Nadia Nell Niko Noor Oona Orrin Otto Paloma Perrin Petra
  59. Quill Remy Rhea Rowan Saoirse Selma Silas Soren Sunniva Tamsin Tobiah Uma Vesper
  60. Wendell Wren Xavi Yara Yusuf Zadie Zev""".split()
  61. LAST = """Abernethy Achterberg Alcott Ambrose Ashdown Barrowclough Beaumont Birchall
  62. Blackwood Bramley Castellanos Cavendish Clemons Coldwell Crane Dalgleish Delacroix
  63. Dunmore Eastwood Ellery Fairbairn Farrow Fenwick Gallardo Garroway Greaves Hadley
  64. Halloran Hartigan Hawthorne Holloway Ingleby Iverson Jardine Kettering Kingsley
  65. Lachance Larkspur Lindgren Lockhart Maddox Marchetti Merriweather Montague Northcott
  66. Oakes Ormsby Pemberton Penhaligon Quimby Radcliffe Ravensworth Redfern Rushworth
  67. Saltonstall Sandoval Seaton Sheffield Stanhope Sutcliffe Tamsworth Thornbury Trevelyan
  68. Underhill Vance Vandermeer Wainwright Westbrook Whitcombe Winterbourne Yardley
  69. Zamora""".split()
  70. CO_A = """Amber Anchor Arbor Aster Basin Beacon Bluff Cedar Cinder Cobalt Copperline
  71. Crescent Driftwood Ember Fernhill Fieldstone Foxglove Granite Harbor Heron Hollow
  72. Juniper Keystone Kestrel Lantern Larch Meadowlark Millrace Northwind Oakmoss Orchard
  73. Pinecrest Prairie Quarry Redwing Ridgeback Saltmarsh Sandpiper Silverleaf Skylark
  74. Stonebridge Sundial Tidewater Timberline Valley Willowbank""".split()
  75. CO_B = ["Events", "Outfitters", "Athletics", "Realty", "Brewing Co.", "Clinic Group",
  76. "Foundation", "Expo Services", "Coffee Roasters", "Academy", "Dental",
  77. "Motorsports", "Credit Union", "Marketing", "Builders", "Film Festival",
  78. "Youth Soccer", "Wellness", "Distillery", "Church", "Robotics", "Farms"]
  79. INDUSTRIES = ["events services", "sports & recreation", "real estate", "food & beverages",
  80. "health care", "nonprofit", "education", "automotive", "financial services",
  81. "marketing & advertising", "construction", "religious institutions",
  82. "entertainment", "retail", "manufacturing"]
  83. TITLES = ["Marketing Manager", "Events Coordinator", "Owner", "Operations Director",
  84. "Brand Manager", "Office Manager", "Community Relations Lead", "Executive Director",
  85. "Trade Show Manager", "Facilities Coordinator", ""]
  86. OWNERS = ["Matt Guerra", "Hazel Okafor", "Nora Lindqvist"]
  87. def iso(d: dt.date) -> str:
  88. return d.isoformat()
  89. class World:
  90. def __init__(self, seed: int = SEED):
  91. self.r = random.Random(seed)
  92. self.accounts: list[dict] = []
  93. self.orders: list[dict] = [] # post-baseline orders only
  94. self.next_account = ACCOUNT_BASE
  95. self.next_order = ORDER_BASE
  96. self.used_names: set[tuple[str, str]] = set()
  97. self.used_companies: set[str] = set()
  98. self.overrides: list[str] = []
  99. self.merges: list[dict] = []
  100. self.suppression = {"names": [], "emails": [], "domains": []}
  101. self.cases: dict[str, dict] = {}
  102. # ── people and companies ──────────────────────────────────────────────
  103. def person(self) -> tuple[str, str]:
  104. while True:
  105. n = (self.r.choice(FIRST), self.r.choice(LAST))
  106. if n not in self.used_names:
  107. self.used_names.add(n)
  108. return n
  109. def company(self) -> tuple[str, str]:
  110. while True:
  111. a, b = self.r.choice(CO_A), self.r.choice(CO_B)
  112. name = f"{a} {b}"
  113. if name not in self.used_companies:
  114. self.used_companies.add(name)
  115. slug = (a + b.split()[0]).lower().replace(".", "")
  116. return name, f"{slug}.example"
  117. def phone(self) -> str:
  118. return f"({self.r.randint(201, 989)}) 555-01{self.r.randint(0, 99):02d}"
  119. def add_account(self, *, ltv: float, last_order: dt.date, orders: int | None = None,
  120. first=None, last=None, company=None, domain=None, email=None,
  121. group: str = "", extra_email: str = "", title=None, industry=None) -> dict:
  122. if first is None:
  123. first, last = self.person()
  124. if company is None:
  125. company, domain = self.company()
  126. if email is None:
  127. email = f"{first.lower()}.{last.lower()}@{domain}".replace(" ", "")
  128. n = orders if orders is not None else max(1, int(ltv // self.r.uniform(900, 2600)) + 1)
  129. span = self.r.randint(120, 1600)
  130. if n == 0:
  131. last_order = None
  132. first_order = (last_order - dt.timedelta(days=span if n > 1 else 0)) if last_order else None
  133. repeat = 0.0 if n == 1 else round(ltv * self.r.uniform(0.35, 0.8), 2)
  134. acc = {
  135. "account_ref": self.next_account, "first_name": first, "last_name": last,
  136. "email": email.lower(), "extra_email": extra_email, "company": company,
  137. "phone": self.phone(), "title": self.r.choice(TITLES) if title is None else title,
  138. "website": f"https://www.{domain}" if domain else "",
  139. "industry": industry or self.r.choice(INDUSTRIES),
  140. "registered": (first_order or BASELINE_AS_OF) - dt.timedelta(days=self.r.randint(0, 400)),
  141. "group": group,
  142. "baseline": {"ltv": round(ltv, 2), "orders": n, "repeat": repeat,
  143. "first": first_order, "last": last_order},
  144. }
  145. self.next_account += 1
  146. self.accounts.append(acc)
  147. return acc
  148. def add_order(self, acc: dict, day: dt.date, amount: float, status: int = 5) -> dict:
  149. ship = round(amount * 0.08, 2)
  150. o = {"order_ref": self.next_order, "account": acc, "day": day,
  151. "grand_total": round(amount + ship, 2), "subtotal": round(amount, 2),
  152. "shipping_total": ship, "status_code": status,
  153. "hour": self.r.randint(8, 18), "minute": self.r.randint(0, 59)}
  154. self.next_order += 1
  155. self.orders.append(o)
  156. return o
  157. # ── the book ──────────────────────────────────────────────────────────
  158. def build(self):
  159. r = self.r
  160. run = RUN_DATES[-1]
  161. dormant_cut = run - dt.timedelta(days=365)
  162. def last_date(dormant: bool) -> dt.date:
  163. if dormant:
  164. return dormant_cut - dt.timedelta(days=r.randint(20, 900))
  165. return BASELINE_AS_OF - dt.timedelta(days=r.randint(3, 330))
  166. # Partner book by band, then Retail accounts under the lowest band.
  167. for count, bi in [(80, 0), (26, 1), (7, 2), (3, 3)]:
  168. tier, lo, hi = BANDS[bi]
  169. for _ in range(count):
  170. ltv = round(r.uniform(lo, hi if bi < 3 else lo * 1.9), 2)
  171. dormant = r.random() < 0.55
  172. grp = tier if r.random() < 0.25 else ""
  173. if grp and bi > 0 and r.random() < 0.3:
  174. grp = BANDS[bi - 1][0] # stale store group: a real mismatch
  175. self.add_account(ltv=ltv, last_order=last_date(dormant), group=grp)
  176. for _ in range(250):
  177. ltv = round(r.uniform(60, 2450), 2)
  178. self.add_account(ltv=ltv, last_order=last_date(r.random() < 0.6),
  179. group="Retail" if r.random() < 0.3 else "")
  180. # Special cases, each named in self.cases for run_sample.py and the docs.
  181. # 1. Directly-partnered account: Rhodium by override, never cold-emailed.
  182. hr = []
  183. for first, last, ltv, n in [("Maren", "Achterberg", 4210.50, 3), ("Soren", "Blackwood", 880.00, 1),
  184. ("Lena", "Coldwell", 0.0, 0), ("Callum", "Dunmore", 12650.25, 6),
  185. ("Rhea", "Farrow", 1540.00, 2), ("Otto", "Garroway", 610.00, 1)]:
  186. self.used_names.add((first, last))
  187. a = self.add_account(ltv=ltv, orders=n, last_order=last_date(False), first=first,
  188. last=last, company="Harlow Ridge Homes", domain="harlowridge.example",
  189. industry="real estate", group="")
  190. hr.append(a)
  191. self.overrides = [a["email"] for a in hr[:4]] # per-email, not per-domain
  192. self.suppression["domains"] = ["harlowridge.example", "meridiandisplay.example"]
  193. self.cases["partnered_account"] = {"overridden": self.overrides,
  194. "not_overridden": [a["email"] for a in hr[4:]]}
  195. # 2. Staff mailbox on the company domain with real spend (event orders).
  196. self.used_names.add(("Theo", "Bassett"))
  197. staff = self.add_account(ltv=31480.00, orders=14, last_order=dormant_cut - dt.timedelta(days=40),
  198. first="Theo", last="Bassett", company="Meridian Display Co.",
  199. domain="meridiandisplay.example", industry="printing", title="Operations")
  200. self.cases["staff_mailbox"] = staff["email"]
  201. # 3. Approved merge: one person, two Plinth accounts after a rebrand.
  202. self.used_names.add(("Imogen", "Radcliffe"))
  203. surv = self.add_account(ltv=6120.40, orders=5, last_order=last_date(False), first="Imogen",
  204. last="Radcliffe", company="Tidewater Foundation", domain="tidewaterfdn.example")
  205. absd = self.add_account(ltv=2310.00, orders=2, last_order=dt.date(2024, 11, 2), first="Imogen",
  206. last="Radcliffe", company="Tidewater Trust", domain="tidewatertrust.example")
  207. self.merges = [{"survivor": str(surv["account_ref"]), "absorbed": [str(absd["account_ref"])],
  208. "unit": "person", "name": "Imogen Radcliffe",
  209. "reason": "Same person across the Tidewater Trust -> Tidewater Foundation rename: "
  210. f"{surv['email']} (live) + {absd['email']} (old domain). Combined "
  211. "lifetime crosses into Platinum.",
  212. "approved_by": "Matt", "approved": "2026-08-06"}]
  213. self.cases["merge"] = {"survivor": surv["account_ref"], "absorbed": absd["account_ref"]}
  214. # 4. Identity twin: same person re-registered under a married name, same email.
  215. self.used_names.update({("Delphine", "Hartigan"), ("Delphine", "Hartigan-Vance")})
  216. tw_a = self.add_account(ltv=5340.00, orders=4, last_order=last_date(False), first="Delphine",
  217. last="Hartigan", company="Lantern Film Festival", domain="lanternfilm.example")
  218. tw_b = self.add_account(ltv=3120.00, orders=2, last_order=last_date(False), first="Delphine",
  219. last="Hartigan-Vance", company="Lantern Film Festival",
  220. domain="lanternfilm.example", email=tw_a["email"])
  221. self.cases["twin"] = {"with_closure": tw_a["account_ref"], "clean_twin": tw_b["account_ref"],
  222. "email": tw_a["email"]}
  223. # 5. Shared company mailbox, two different people.
  224. for first, last in [("Nell", "Ormsby"), ("Idris", "Seaton")]:
  225. self.used_names.add((first, last))
  226. self.add_account(ltv=r.uniform(2800, 7000), last_order=last_date(False), first=first,
  227. last=last, company="Lakeshore Arts Council", domain="lakeshorearts.example",
  228. email="events@lakeshorearts.example")
  229. self.cases["shared_mailbox"] = "events@lakeshorearts.example"
  230. # 6. Role-named account (dormant, so Winback holds it for review).
  231. role = self.add_account(ltv=9870.00, orders=7, last_order=dormant_cut - dt.timedelta(days=200),
  232. first="Purchasing", last="Dept", company="Northwind Academy",
  233. domain="northwindacademy.example", email="purchasing@northwindacademy.example",
  234. title="")
  235. self.cases["role_named"] = role["email"]
  236. # 7. Messy shipping-label name (the stub gets flagged on creation).
  237. messy = self.add_account(ltv=3360.00, orders=2, last_order=last_date(False), first="Booth 214",
  238. last="c/o Riverside Expo", company="Riverside Expo Center",
  239. domain="riversideexpo.example", email="booth214@riversideexpo.example",
  240. title="")
  241. self.cases["messy_name"] = messy["email"]
  242. # ── post-baseline orders (the weekly pulls) ──
  243. partners = [a for a in self.accounts if a["baseline"]["ltv"] >= 2500]
  244. # Small accounts well under the Palladium line, so routine orders don't
  245. # push them over it (the one planned crossing is added below).
  246. retail = [a for a in self.accounts if a["baseline"]["ltv"] < 1600 and a["company"] != "Harlow Ridge Homes"]
  247. # Routine orders go to clients already buying this year, and stay clear of
  248. # a band edge, so the week's tier movement is only the planned crossings.
  249. tops = [lo for _, lo, _ in BANDS]
  250. buying = [a for a in partners if a["baseline"]["last"] >= dormant_cut
  251. and a["email"] != self.cases["role_named"]]
  252. added: dict[int, float] = {}
  253. def headroom(a):
  254. now = a["baseline"]["ltv"] + added.get(a["account_ref"], 0.0)
  255. return min([top - now for top in tops if top > now] or [1e9])
  256. def pick():
  257. while True:
  258. a, amount = ((r.choice(buying), round(r.uniform(150, 1400), 2)) if r.random() < 0.7
  259. else (r.choice(retail), round(r.uniform(60, 350), 2)))
  260. if amount * 1.08 < headroom(a) - 50:
  261. added[a["account_ref"]] = added.get(a["account_ref"], 0.0) + amount * 1.08
  262. return a, amount
  263. days = [BASELINE_AS_OF + dt.timedelta(days=i) for i in range(0, 15)]
  264. for d in days:
  265. if d.weekday() >= 5:
  266. continue
  267. for _ in range(r.randint(3, 7)):
  268. acc, amount = pick()
  269. self.add_order(acc, d, amount)
  270. # The busy day: enough orders that Plinth truncates the response.
  271. for _ in range(64):
  272. acc, amount = pick()
  273. self.add_order(acc, BUSY_DAY, amount)
  274. # Cancelled and refunded orders that must not count toward lifetime value.
  275. self.add_order(r.choice(partners), dt.date(2026, 9, 10), 2200.00, status=27)
  276. self.add_order(r.choice(partners), dt.date(2026, 9, 16), 1450.00, status=28)
  277. # Tier crossings mid-week.
  278. near = [a for a in self.accounts if 2000 < a["baseline"]["ltv"] < 2450
  279. and a["company"] != "Harlow Ridge Homes"]
  280. up_retail = max(near, key=lambda a: a["baseline"]["ltv"])
  281. self.add_order(up_retail, dt.date(2026, 9, 16), 640.00)
  282. pal = [a for a in partners if 7300 < a["baseline"]["ltv"] < 8000]
  283. up_pal = pal[0] if pal else partners[0]
  284. self.add_order(up_pal, dt.date(2026, 9, 18), 1120.00)
  285. # A brand-new customer whose first order qualifies them.
  286. self.used_names.add(("Wendell", "Pemberton"))
  287. newbie = {"account_ref": self.next_account, "first_name": "Wendell", "last_name": "Pemberton",
  288. "email": "wendell@pembertonrobotics.example", "extra_email": "",
  289. "company": "Pemberton Robotics", "phone": self.phone(), "title": "Founder",
  290. "website": "https://www.pembertonrobotics.example", "industry": "manufacturing",
  291. "registered": dt.date(2026, 9, 11), "group": "", "baseline": None}
  292. self.next_account += 1
  293. self.accounts.append(newbie)
  294. self.add_order(newbie, dt.date(2026, 9, 19), 3875.00)
  295. self.cases["crossings"] = {"retail_to_palladium": up_retail["account_ref"],
  296. "palladium_to_platinum": up_pal["account_ref"],
  297. "new_customer": newbie["account_ref"]}
  298. return self
  299. # ── writers ──────────────────────────────────────────────────────────────────
  300. def plinth_order(o: dict) -> dict:
  301. a = o["account"]
  302. ts = f"{iso(o['day'])} {o['hour']:02d}:{o['minute']:02d}:00"
  303. # Plinth sends some numbers as strings and some as numbers; so does the sample.
  304. gt = o["grand_total"] if o["order_ref"] % 3 else f"{o['grand_total']:.2f}"
  305. return {
  306. "order_ref": o["order_ref"], "account_ref": a["account_ref"], "email": a["email"],
  307. "first_name": a["first_name"], "last_name": a["last_name"], "company_name": a["company"],
  308. "phone": a["phone"], "customer_group": a["group"], "grand_total": gt,
  309. "subtotal": o["subtotal"], "shipping_total": o["shipping_total"], "tax_total": 0,
  310. "status_code": o["status_code"],
  311. "status_label": {27: "Order Cancelled", 28: "Order Refunded"}.get(o["status_code"], "Completed"),
  312. "closed_on": ts, "shop": 1, "lines": {"1": {"line_ref": o["order_ref"] * 10 + 1}},
  313. "shipments": [],
  314. }
  315. def write_pages(w: World, out: Path):
  316. """One file per (day, offset) API response, as Claude would save them.
  317. Days with more orders than TRUNCATE_AT come back cut short, and the next
  318. offset overlaps the previous page instead of continuing after it."""
  319. pdir = out / "plinth"
  320. pdir.mkdir(parents=True, exist_ok=True)
  321. by_day: dict[dt.date, list] = {}
  322. for o in w.orders:
  323. by_day.setdefault(o["day"], []).append(o)
  324. first = RUN_DATES[0] - dt.timedelta(days=PULL_WINDOW_DAYS - 1)
  325. d = first
  326. while d <= RUN_DATES[-1]:
  327. rows = sorted(by_day.get(d, []), key=lambda o: o["order_ref"])
  328. if len(rows) <= TRUNCATE_AT:
  329. pages = [rows]
  330. else:
  331. pages = [rows[:TRUNCATE_AT], rows[20:20 + TRUNCATE_AT], rows[30:]]
  332. for i, page in enumerate(pages):
  333. body = ({"order_map": {f"ord_{o['order_ref']}": plinth_order(o) for o in page}}
  334. if page else {"notice": "No match"})
  335. (pdir / f"{iso(d)}_p{i}.json").write_text(json.dumps(body, indent=1), encoding="utf-8")
  336. d += dt.timedelta(days=1)
  337. def write_roster(w: World, out: Path):
  338. """The partner roster workbook, same tab and column contract as the real one."""
  339. from openpyxl import Workbook
  340. from openpyxl.styles import Font, PatternFill
  341. wb = Workbook()
  342. work = wb.active
  343. work.title = "2026 Partner Working List"
  344. part = wb.create_sheet("Complete Partner Clients List")
  345. allacc = wb.create_sheet("All Accounts")
  346. date_us = BASELINE_AS_OF.strftime("%m/%d/%y")
  347. ph = ["Id", "First Name", "Last Name", "Email", "Additional Email", "Company", "Phone",
  348. "Job Title", "Website", "User Group", "Correct Group", "Updated Group Plinth?",
  349. "Date Updated Plinth", f"Plinth LTV {date_us}", "Total Spent (Orders)", "Last Order",
  350. "Industry", "Registered", "# Orders", "Repeat Revenue (Plinth)", "First Order",
  351. "Pipeline(s)", "Comments", "Contacted (Pipeline Outreach)", "Most Recent Contact Date",
  352. "First Outreach Date", "Times Contacted (Pipeline Touches)", "Total Pipeline Entries",
  353. "Current Pipeline Stage(s)", "Pipeline Owner/Rep"]
  354. wh = ["Completed"] + ph[:9] + ["User Type"] + ph[9:13] + ["Social Media", "Total Spent (Orders)",
  355. "Industry", "Registered", "# Orders", "Repeat Revenue (Plinth)", "First Order", "Last Order",
  356. "Pipeline(s)", "Comments", "Contacted (Pipeline Outreach)", "Most Recent Contact Date",
  357. "First Outreach Date", "Times Contacted (Pipeline Touches)", "Total Pipeline Entries",
  358. "Current Pipeline Stage(s)", "Pipeline Owner/Rep", "LTV (Plinth)"]
  359. head = Font(name="Arial", bold=True, color="FFFFFF")
  360. fill = PatternFill("solid", fgColor="1F3864")
  361. for ws, hdr in ((part, ph), (work, wh), (allacc, ["ID", "First Name", "Last Name", "Email", "LTV", "RR"])):
  362. ws.append(hdr)
  363. for c in ws[1]:
  364. c.font, c.fill = head, fill
  365. def tier_of(ltv):
  366. for t, lo, hi in BANDS:
  367. if ltv >= lo and (ltv < hi or t == "Rhodium Partner"):
  368. return t
  369. return ""
  370. money, day = "$#,##0.00", "mm/dd/yyyy"
  371. body = Font(name="Arial", size=10)
  372. for a in w.accounts:
  373. b = a["baseline"]
  374. if b is None:
  375. continue
  376. allacc.append([a["account_ref"], a["first_name"], a["last_name"], a["email"], b["ltv"], b["repeat"]])
  377. tier = tier_of(b["ltv"])
  378. if a["email"] in w.overrides:
  379. tier = "Rhodium Partner"
  380. if not tier:
  381. continue
  382. part.append([a["account_ref"], a["first_name"], a["last_name"], a["email"], a["extra_email"],
  383. a["company"], a["phone"], a["title"], a["website"], a["group"], tier, "", None,
  384. b["ltv"], b["ltv"], b["last"], a["industry"], a["registered"], b["orders"],
  385. b["repeat"], b["first"], "", "", "", None, None, None, None, "", ""])
  386. for ws in (part, allacc):
  387. for row in ws.iter_rows(min_row=2):
  388. for c in row:
  389. c.font = body
  390. h = ws.cell(row=1, column=c.column).value or ""
  391. if h.startswith("Plinth LTV") or h in ("Total Spent (Orders)", "Repeat Revenue (Plinth)", "LTV", "RR"):
  392. c.number_format = money
  393. if h in ("Last Order", "First Order", "Registered", "Date Updated Plinth"):
  394. c.number_format = day
  395. # The curated working tab: a hand-kept subset with human columns filled in.
  396. r = random.Random(SEED + 1)
  397. notes = ["Prefers a call before quoting.", "Orders ahead of the spring show circuit.",
  398. "Asked about storage for booth kits.", "New marketing lead since spring.",
  399. "Uses our design studio for every order.", ""]
  400. for a in [x for x in w.accounts if x["baseline"] and x["baseline"]["ltv"] >= 8000][:24]:
  401. b = a["baseline"]
  402. work.append(["", a["account_ref"], a["first_name"], a["last_name"], a["email"], "",
  403. a["company"], a["phone"], a["title"], a["website"], "Customer", a["group"],
  404. tier_of(b["ltv"]), "", None, "", b["ltv"], a["industry"], a["registered"],
  405. b["orders"], b["repeat"], b["first"], b["last"], "", r.choice(notes),
  406. "", None, None, None, None, "", r.choice(OWNERS), b["ltv"]])
  407. out.mkdir(parents=True, exist_ok=True)
  408. path = out / f"Partner-Clients-Roster_{iso(BASELINE_AS_OF)}.xlsx"
  409. wb.save(path)
  410. return path
  411. # ── Bitrix: two weekly enumerations and the stage histories behind them ─────
  412. STAGES = {
  413. "24": {"research": "UC_2YNXWE", "tier_email": "UC_6H9C8U", "artwork": "UC_5DTN59",
  414. "mockup": "PREPAYMENT_INVOIC", "gift_sent": "FINAL_INVOICE", "won": "WON",
  415. "lose": "LOSE", "retired_send": "PREPARATION", "retired_2nd": "UC_CEAGAV",
  416. "retired_bulk": "UC_N4JCMW"},
  417. "26": {"client": "UC_JUUNES", "activated": "UC_L2JMHY", "first": "PREPARATION",
  418. "noreply1": "UC_K9M6LT", "follow": "PREPAYMENT_INVOIC", "precall": "FINAL_INVOICE",
  419. "won": "WON", "lose": "LOSE", "survey": "UC_PMLDJW"},
  420. "36": {"active": "NEW", "dormant": "UC_LXCMAV", "first": "PREPAYMENT_INVOIC",
  421. "second": "EXECUTING", "noreply": "UC_7XQR9H", "won": "WON", "notint": "1"},
  422. "16": {"new": "NEW", "first": "PREPARATION", "follow": "PREPAYMENT_INVOIC",
  423. "noreply2": "EXECUTING", "won": "WON", "lose": "LOSE"},
  424. }
  425. def build_deals(w: World):
  426. """Returns (week1_deals, week2_deals, histories, contacts). Deal JSON shape is
  427. what the SOP's enumeration step saves: deal_id, cat, stage, stage_date, title,
  428. owner, created, value. Histories and contacts are what get_stage_history and
  429. get_record would return, attached by run_sample.py only for the deals the
  430. pull list asks for."""
  431. r = random.Random(SEED + 2)
  432. run = RUN_DATES[-1]
  433. cut = run - dt.timedelta(days=365)
  434. by_ref = {a["account_ref"]: a for a in w.accounts}
  435. partners = [a for a in w.accounts if a["baseline"] and a["baseline"]["ltv"] >= 2500
  436. and a["company"] not in ("Harlow Ridge Homes", "Meridian Display Co.")
  437. and a["email"] not in (w.cases["shared_mailbox"], w.cases["role_named"], w.cases["messy_name"])]
  438. dormant = [a for a in partners if a["baseline"]["last"] < cut]
  439. active = [a for a in partners if a["baseline"]["last"] >= cut]
  440. twin_a = by_ref[w.cases["twin"]["with_closure"]]
  441. twin_b = by_ref[w.cases["twin"]["clean_twin"]]
  442. dormant = [a for a in dormant if a not in (twin_a, twin_b)]
  443. active = [a for a in active if a not in (twin_a, twin_b)]
  444. r.shuffle(dormant)
  445. r.shuffle(active)
  446. deals, hist, contacts = [], {}, {}
  447. next_id = [DEAL_BASE]
  448. def at(d: dt.date, h=None, m=None):
  449. return f"{iso(d)} {h if h is not None else r.randint(8, 17):02d}:{m if m is not None else r.randint(0, 59):02d}"
  450. def deal(cat, a, path, created, *, title=None, value="", owner=None, deal_id=None):
  451. """path: list of (stage_key, date). The deal sits in the last stage."""
  452. did = deal_id or str(next_id[0])
  453. if not deal_id:
  454. next_id[0] += r.randint(3, 40)
  455. st = STAGES[cat]
  456. h = [{"stage": f"C{cat}:{st[k] if k in st else k}", "at": at(d)} for k, d in path]
  457. last_key, last_day = path[-1]
  458. stage = f"C{cat}:{st[last_key] if last_key in st else last_key}"
  459. deals.append({"deal_id": did, "cat": cat, "stage": stage, "stage_date": at(last_day),
  460. "title": title or f"{a['first_name']} {a['last_name']}",
  461. "owner": owner or r.choice(OWNERS), "created": at(created, 9, 0), "value": value})
  462. hist[did] = h
  463. if a is not None:
  464. contacts[did] = {"name": f"{a['first_name']} {a['last_name']}", "email": a["email"],
  465. "phone": a["phone"]}
  466. return did
  467. d = dt.date
  468. # Winback (26): most sit untouched in the Client bucket; Nora's stage moves are the sends.
  469. for a in dormant[:12]:
  470. deal("26", a, [("client", d(2026, 8, 6))], d(2026, 8, 6), owner="Nora Lindqvist")
  471. for a in dormant[12:15]:
  472. deal("26", a, [("client", d(2026, 8, 6)), ("activated", d(2026, 8, 11))], d(2026, 8, 6),
  473. owner="Nora Lindqvist")
  474. for a in dormant[15:18]:
  475. deal("26", a, [("client", d(2026, 8, 6)), ("activated", d(2026, 8, 11)), ("first", d(2026, 8, 13)),
  476. ("noreply1", d(2026, 8, 20)), ("follow", d(2026, 8, 24))], d(2026, 8, 6),
  477. owner="Nora Lindqvist")
  478. a = dormant[18]
  479. did = deal("26", a, [("client", d(2026, 8, 6)), ("activated", d(2026, 8, 11)), ("first", d(2026, 8, 12)),
  480. ("follow", d(2026, 8, 19)), ("precall", d(2026, 8, 27))], d(2026, 8, 6),
  481. owner="Nora Lindqvist")
  482. # Same-minute workflow re-entries: one send, not three.
  483. hist[did] += [{"stage": "C26:PREPAYMENT_INVOIC", "at": "2026-08-19 10:14"},
  484. {"stage": "C26:PREPAYMENT_INVOIC", "at": "2026-08-19 10:15"}]
  485. for a in dormant[19:21]:
  486. deal("26", a, [("first", d(2026, 2, 10)), ("survey", d(2026, 2, 12)), ("won", d(2026, 3, 30))],
  487. d(2026, 2, 9), owner="Hazel Okafor")
  488. deal("26", dormant[21], [("first", d(2026, 2, 10)), ("follow", d(2026, 2, 17)), ("lose", d(2026, 4, 1))],
  489. d(2026, 2, 9), owner="Hazel Okafor")
  490. # Winback already done this dormancy spell: excluded from routing, not re-touched.
  491. deal("26", dormant[22], [("client", d(2026, 6, 2)), ("first", d(2026, 6, 4)), ("lose", d(2026, 7, 15))],
  492. d(2026, 6, 2), owner="Nora Lindqvist")
  493. # Partner Tier Upgrade (24): first touches and tier crossings on active clients.
  494. for a in active[:4]:
  495. deal("24", a, [("research", d(2026, 7, 20)), ("tier_email", d(2026, 7, 21))], d(2026, 7, 20),
  496. owner="Hazel Okafor")
  497. deal("24", active[4], [("tier_email", d(2026, 7, 21)), ("artwork", d(2026, 7, 29))], d(2026, 7, 20))
  498. deal("24", active[5], [("tier_email", d(2026, 6, 24)), ("mockup", d(2026, 7, 2)),
  499. ("gift_sent", d(2026, 7, 14))], d(2026, 6, 23))
  500. for a in active[6:8]:
  501. # Older deals that moved through stages since deleted from the funnel.
  502. deal("24", a, [("retired_send", d(2026, 3, 3)), ("retired_2nd", d(2026, 3, 17)),
  503. ("retired_bulk", d(2026, 4, 1)), ("won", d(2026, 4, 20))], d(2026, 3, 2),
  504. owner="Hazel Okafor")
  505. deal("24", active[8], [("research", d(2026, 8, 12))], d(2026, 8, 12))
  506. # The twin with the Not Interested closure (an opt-out for the whole identity).
  507. deal("24", twin_a, [("tier_email", d(2026, 6, 24)), ("lose", d(2026, 7, 8))], d(2026, 6, 23),
  508. owner="Hazel Okafor")
  509. # Quarterly Check-In (36).
  510. for a in active[9:13]:
  511. deal("36", a, [("active", d(2026, 8, 6))], d(2026, 8, 6), owner="Matt Guerra")
  512. # Dual-active: an open tier-upgrade deal parks the check-in in Dormant.
  513. for a in active[:2]:
  514. deal("36", a, [("active", d(2026, 5, 12)), ("dormant", d(2026, 8, 12))], d(2026, 5, 12),
  515. owner="Matt Guerra")
  516. for a in active[13:15]:
  517. deal("36", a, [("active", d(2026, 7, 27)), ("first", d(2026, 7, 29))], d(2026, 7, 27),
  518. owner="Matt Guerra")
  519. deal("36", active[15], [("first", d(2026, 7, 29)), ("noreply", d(2026, 8, 5)), ("second", d(2026, 8, 11))],
  520. d(2026, 7, 27), owner="Matt Guerra")
  521. deal("36", active[16], [("first", d(2026, 4, 14)), ("won", d(2026, 5, 2))], d(2026, 4, 13),
  522. owner="Hazel Okafor")
  523. deal("36", active[17], [("first", d(2026, 7, 29)), ("notint", d(2026, 8, 3))], d(2026, 7, 27),
  524. owner="Matt Guerra")
  525. deal("36", active[18], [("first", d(2026, 4, 14)), ("second", d(2026, 4, 28)), ("won", d(2026, 5, 20))],
  526. d(2026, 4, 13), owner="Matt Guerra")
  527. # Abandoned Carts (16): titles are cart numbers, so contacts come from get_record.
  528. retail = [a for a in w.accounts if a["baseline"] and a["baseline"]["ltv"] < 2500
  529. and a["company"] != "Harlow Ridge Homes"]
  530. for i, a in enumerate(retail[:5]):
  531. path = [("new", d(2026, 9, 2 + i))]
  532. if i % 2:
  533. path.append(("first", d(2026, 9, 3 + i)))
  534. deal("16", a, path, d(2026, 9, 2 + i), title=f"Cart - {70100 + i * 7}",
  535. value=round(r.uniform(90, 900), 2), owner="Hazel Okafor")
  536. # A guest checkout that is not a Plinth account at all.
  537. guest = {"first_name": "Selma", "last_name": "Iverson", "email": "selma.iverson@quarrybrewing.example",
  538. "phone": "(512) 555-0147"}
  539. deal("16", guest, [("new", d(2026, 9, 9))], d(2026, 9, 9), title="Cart - 70177", value=412.50,
  540. owner="Hazel Okafor")
  541. # A cart that converted and moved on into production funnels (C18/C20 entries
  542. # must not count as cart emails even though PREPARATION is a cat-16 send token).
  543. conv = retail[6]
  544. did = deal("16", conv, [("new", d(2026, 8, 25)), ("first", d(2026, 8, 26)), ("won", d(2026, 8, 28))],
  545. d(2026, 8, 25), title="Cart - 70093", value=655.00, owner="Hazel Okafor")
  546. hist[did] += [{"stage": "C18:PREPARATION", "at": "2026-08-29 09:12"},
  547. {"stage": "C20:PREPARATION", "at": "2026-09-01 11:40"}]
  548. for tid in TEST_DEALS:
  549. deal("16", None, [("new", d(2026, 8, 20))], d(2026, 8, 20), title=f"TEST cart {tid}",
  550. value=1.00, owner="Matt Guerra", deal_id=tid)
  551. # Week 1 is the same book one week earlier: deals that moved in week 2 sat one stage back.
  552. week2 = [dict(x) for x in deals]
  553. week1 = []
  554. moved = {}
  555. for x in deals:
  556. h = sorted(hist[x["deal_id"]], key=lambda e: e["at"])
  557. before = [e for e in h if e["at"][:10] <= iso(RUN_DATES[0])]
  558. if not before:
  559. continue # created in week 2
  560. y = dict(x)
  561. if before[-1]["stage"] != x["stage"]:
  562. y["stage"], y["stage_date"] = before[-1]["stage"], before[-1]["at"]
  563. moved[x["deal_id"]] = True
  564. week1.append(y)
  565. # Two week-2 moves, so the cache has something real to decide about.
  566. for x in week2:
  567. if x["cat"] == "36" and x["stage"] == "C36:NEW":
  568. x["stage"], x["stage_date"] = "C36:PREPAYMENT_INVOIC", "2026-09-16 10:20"
  569. hist[x["deal_id"]].append({"stage": "C36:PREPAYMENT_INVOIC", "at": "2026-09-16 10:20"})
  570. break
  571. for x in week2:
  572. if x["cat"] == "26" and x["stage"] == "C26:UC_L2JMHY":
  573. x["stage"], x["stage_date"] = "C26:PREPARATION", "2026-09-17 14:05"
  574. hist[x["deal_id"]].append({"stage": "C26:PREPARATION", "at": "2026-09-17 14:05"})
  575. break
  576. return week1, week2, hist, contacts
  577. def main():
  578. ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
  579. ap.add_argument("--out", default=str(HERE / "inputs"))
  580. a = ap.parse_args()
  581. out = Path(a.out)
  582. w = World().build()
  583. write_pages(w, out)
  584. roster = write_roster(w, out)
  585. week1, week2, hist, contacts = build_deals(w)
  586. bdir = out / "bitrix"
  587. bdir.mkdir(parents=True, exist_ok=True)
  588. for day, deals in zip(RUN_DATES, (week1, week2)):
  589. (bdir / f"bitrix-deals_{iso(day)}.json").write_text(
  590. json.dumps({"deals": deals}, indent=1), encoding="utf-8")
  591. (bdir / "stage-histories.json").write_text(json.dumps(hist, indent=1, sort_keys=True), encoding="utf-8")
  592. (bdir / "contacts.json").write_text(json.dumps(contacts, indent=1, sort_keys=True), encoding="utf-8")
  593. (out / "merge_registry.json").write_text(json.dumps({
  594. "_readme": "Approved Plinth account merges. The weekly pipeline folds each absorbed account "
  595. "into its survivor at classify time (lifetime metrics), drops absorbed pl-<id> "
  596. "chunks from the XML census, and never re-creates them. Keys are "
  597. "order_history.json customer keys (the Plinth account id as a string). Add "
  598. "entries ONLY with Matt's explicit approval, one LEDGER entry per addition.",
  599. "version": 1, "updated": "2026-08-06", "merges": w.merges}, indent=1), encoding="utf-8")
  600. (out / "suppression_list.json").write_text(json.dumps({
  601. "_readme": "Explicit email opt-outs and never-contact accounts. A client here, or with any "
  602. "Not-Interested funnel closure, never routes into any outreach pipeline, "
  603. "permanently, until Matt personally reverses it (DECISIONS 2026-08-06). Names "
  604. "match on normalized full name; emails on exact lowercase; domains match the "
  605. "part after '@', lowercase.",
  606. "_domains_note": "domains: whole companies that are never cold-outreached because the "
  607. "relationship is handled directly (DECISIONS 2026-08-12). "
  608. "harlowridge.example = Harlow Ridge Homes, a directly-partnered builder; "
  609. "meridiandisplay.example = our own staff.",
  610. **w.suppression}, indent=1), encoding="utf-8")
  611. (out / "cases.json").write_text(json.dumps({**w.cases, "tier_overrides": w.overrides},
  612. indent=1, default=str), encoding="utf-8")
  613. n_orders = len(w.orders)
  614. print(f"accounts {len(w.accounts)}, post-baseline orders {n_orders}, "
  615. f"deals week1 {len(week1)} / week2 {len(week2)} -> {out}")
  616. print(f"roster: {roster.name}")
  617. if __name__ == "__main__":
  618. main()