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

audit.py

Language Python18.3 KB375 lines

07-Automation/partner_pipeline/audit.py

  1. """outreach-pull-list and outreach-audit: who was actually contacted, and when."""
  2. from __future__ import annotations
  3. import datetime as dt
  4. import json
  5. import sys
  6. from pathlib import Path
  7. from .audit_workbook import UNRESOLVED_MATCHES, write_audit_workbook
  8. from .census import norm_key
  9. from .paths import DATA_DIR, NOW, OUT_DIR, TODAY, TOUCH_CACHE, XML_MAP
  10. from .roster_workbook import ALL_TAB, PARTNER_TAB, latest_updated_workbook
  11. from .stages import AUDIT_FUNNELS, SEND_TOKENS, TEST_DEALS, audit_stage_name, is_terminal
  12. SAME_SEND_GAP_MIN = 2 # two entries into the same stage this close = one send
  13. def _reusable(cat: str, cached: dict, stage_id: str) -> bool:
  14. """Can this deal's cached touch history be reused instead of re-pulled?
  15. Two cases, both requiring the current stage to be unchanged since the cache was
  16. written (the caller checks that):
  17. 1. Terminal stage: the deal is closed, nothing more can happen to it.
  18. 2. Zero recorded touches: the deal has never entered a send stage, so it cannot
  19. be sitting in one now, and a send would have moved it. This is what keeps the
  20. big parked cohorts cheap: Winback "Client", Abandoned Carts "New Abandoned
  21. Cart", cat 24 "LinkedIn Research & Connection", cat 36 "Active Check-In List"
  22. hold most of the deals, and nobody has touched them.
  23. Residual risk in both cases is the same: a deal that entered a send stage and
  24. returned to its original stage between two runs would be missed. It self-corrects
  25. the next time its stage actually changes. Deals with touches sitting in a send
  26. stage are always re-pulled, because a repeat send re-enters the same stage and
  27. that is a real pattern (cat 26 Follow-Up Email).
  28. """
  29. if is_terminal(cat, stage_id):
  30. return True
  31. return int(cached.get("count") or 0) == 0
  32. def _parse_ts(s):
  33. if not s or str(s).strip().upper() in ("", "NONE", "NULL"):
  34. return None
  35. s = str(s).strip().replace("T", " ")[:16]
  36. try:
  37. return dt.datetime.strptime(s, "%Y-%m-%d %H:%M")
  38. except ValueError:
  39. try:
  40. return dt.datetime.strptime(s[:10], "%Y-%m-%d")
  41. except ValueError:
  42. return None
  43. def compute_touches(cat: str, history: list) -> dict:
  44. """Reduce a raw stage history to verified outbound contact.
  45. history: [{"stage": "C36:EXECUTING", "at": "2026-07-29 16:40"}, ...] any order.
  46. Returns {first, last, last_stage, count} with datetimes (or None).
  47. Only SEND_TOKENS count. Repeat entries into the SAME stage within
  48. SAME_SEND_GAP_MIN minutes collapse to one send: Bitrix workflows re-enter a
  49. stage several times in the same minute and that is one email, not four.
  50. Re-entries days apart DO count separately; the `count` is therefore a
  51. best-effort figure while `last` is the reliable one.
  52. """
  53. sends = []
  54. for h in history or []:
  55. stage = h.get("stage") or ""
  56. # A deal can be moved between funnels, so its history legitimately contains
  57. # stages from other categories (e.g. an Abandoned Cart that converted and went
  58. # on through Pre-Production/Production carries C18:* and C20:* entries).
  59. # Token-only matching would then count C20:PREPARATION as an abandoned-cart
  60. # email, because "PREPARATION" is a send token in cat 16. Require the category
  61. # prefix to match the funnel we are auditing; a bare token with no prefix is
  62. # assumed to belong to this funnel.
  63. if ":" in stage:
  64. h_cat, token = stage.split(":", 1)
  65. h_cat = h_cat.lstrip("Cc")
  66. if h_cat != cat:
  67. continue
  68. else:
  69. token = stage
  70. if token not in SEND_TOKENS.get(cat, set()):
  71. continue
  72. ts = _parse_ts(h.get("at"))
  73. if ts is None:
  74. continue
  75. sends.append((ts, token, stage))
  76. sends.sort()
  77. kept = []
  78. for ts, token, stage in sends:
  79. if kept and kept[-1][1] == token and (ts - kept[-1][0]).total_seconds() <= SAME_SEND_GAP_MIN * 60:
  80. continue
  81. kept.append((ts, token, stage))
  82. if not kept:
  83. return {"first": None, "last": None, "last_stage": "", "count": 0}
  84. return {"first": kept[0][0], "last": kept[-1][0],
  85. "last_stage": audit_stage_name(cat, kept[-1][2]), "count": len(kept)}
  86. def _load_touch_cache() -> dict:
  87. if TOUCH_CACHE.exists():
  88. try:
  89. return json.loads(TOUCH_CACHE.read_text(encoding="utf-8"))
  90. except Exception as e:
  91. print(f"WARNING: touch cache unreadable ({e}): treating every deal as new.")
  92. return {}
  93. def _build_plinth_index(workbook: Path | None):
  94. """Identity lookup for the roster owner: Plinth id, email, phone, company, tier, LTV.
  95. Three sources, most specific first:
  96. 1. the XML census -> bitrix_deal_id -> plinth_id (exact, by deal)
  97. 2. partner tab of the Drive-swap workbook -> phone/company/tier/LTV
  98. 3. All Accounts of the same workbook = the full Plinth customer list (every account),
  99. which is the only place non-partner people (cart guests, dormant winback)
  100. can be found at all.
  101. """
  102. import xml.etree.ElementTree as ET
  103. by_deal, prem, prem_email, prem_name, plinth_email, plinth_name = {}, {}, {}, {}, {}, {}
  104. if XML_MAP.exists():
  105. root = ET.parse(XML_MAP).getroot()
  106. for c in root.findall("client"):
  107. rec = {"plinth": (c.findtext("plinth_id") or "").strip(),
  108. "email": (c.findtext("email") or "").strip(),
  109. "company": (c.findtext("company") or "").strip(),
  110. "tier": (c.findtext("user_group") or "").strip()}
  111. for blk in c.iter("pipeline"):
  112. d = (blk.findtext("bitrix_deal_id") or "").strip()
  113. if d:
  114. by_deal[d] = rec
  115. if workbook and workbook.exists():
  116. try:
  117. from openpyxl import load_workbook
  118. wb = load_workbook(workbook, read_only=True, data_only=True)
  119. if PARTNER_TAB in wb.sheetnames:
  120. for i, row in enumerate(wb[PARTNER_TAB].iter_rows(values_only=True)):
  121. if i == 0 or not row or row[0] is None:
  122. continue
  123. sid = str(int(row[0])) if isinstance(row[0], (int, float)) else str(row[0]).strip()
  124. rec = {"plinth": sid, "email": row[3], "company": row[5], "phone": row[6],
  125. "tier": row[10] or row[9], "ltv": row[13]}
  126. prem[sid] = rec
  127. for e in (row[3], row[4]):
  128. if e and "@" in str(e):
  129. prem_email.setdefault(str(e).strip().lower(), rec)
  130. n = norm_key(f"{row[1]}{row[2]}")
  131. if n:
  132. prem_name.setdefault(n, rec)
  133. if ALL_TAB in wb.sheetnames:
  134. for i, row in enumerate(wb[ALL_TAB].iter_rows(values_only=True)):
  135. if i == 0 or not row or row[0] is None:
  136. continue
  137. sid = str(int(row[0])) if isinstance(row[0], (int, float)) else str(row[0]).strip()
  138. rec = {"plinth": sid, "email": row[3], "ltv": row[4]}
  139. if row[3] and "@" in str(row[3]):
  140. plinth_email.setdefault(str(row[3]).strip().lower(), rec)
  141. n = norm_key(f"{row[1]}{row[2]}")
  142. if n:
  143. plinth_name.setdefault(n, rec)
  144. wb.close()
  145. except ImportError:
  146. print("NOTE: openpyxl missing: Plinth identity columns will be blank.")
  147. return by_deal, prem, prem_email, prem_name, plinth_email, plinth_name
  148. def resolve_plinth_identity(idx, deal_id, name, email=None) -> dict:
  149. by_deal, prem, prem_email, prem_name, plinth_email, plinth_name = idx
  150. out = {"plinth": "", "email": email or "", "company": "", "phone": "",
  151. "tier": "", "ltv": None, "match": "UNRESOLVED"}
  152. c = by_deal.get(str(deal_id))
  153. if c and c["plinth"]:
  154. out.update(plinth=c["plinth"], email=c["email"] or out["email"],
  155. company=c["company"], tier=c["tier"], match="deal->census")
  156. p = prem.get(c["plinth"])
  157. if p:
  158. out["phone"] = p.get("phone") or ""
  159. out["ltv"] = p.get("ltv")
  160. out["company"] = out["company"] or (p.get("company") or "")
  161. out["email"] = out["email"] or (p.get("email") or "")
  162. out["tier"] = out["tier"] or (p.get("tier") or "")
  163. return out
  164. if email:
  165. e = str(email).strip().lower()
  166. p = prem_email.get(e)
  167. if p:
  168. return {"plinth": p["plinth"], "email": p.get("email") or email,
  169. "company": p.get("company") or "", "phone": p.get("phone") or "",
  170. "tier": p.get("tier") or "", "ltv": p.get("ltv"),
  171. "match": "email->partner"}
  172. o = plinth_email.get(e)
  173. if o:
  174. return {"plinth": o["plinth"], "email": o.get("email") or email, "company": "",
  175. "phone": "", "tier": "not partner", "ltv": o.get("ltv"),
  176. "match": "email->Plinth census"}
  177. n = norm_key(name)
  178. if n and n in prem_name:
  179. p = prem_name[n]
  180. return {"plinth": p["plinth"], "email": p.get("email") or "", "company": p.get("company") or "",
  181. "phone": p.get("phone") or "", "tier": p.get("tier") or "",
  182. "ltv": p.get("ltv"), "match": "name->partner"}
  183. if n and n in plinth_name:
  184. o = plinth_name[n]
  185. return {"plinth": o["plinth"], "email": o.get("email") or "", "company": "", "phone": "",
  186. "tier": "not partner", "ltv": o.get("ltv"), "match": "name->Plinth census"}
  187. if email:
  188. out["match"] = "email only (no Plinth match)"
  189. return out
  190. def cmd_outreach_pull_list(args):
  191. """Decide which deals actually need a fresh stage-history pull this week.
  192. Reads the weekly enumeration JSON and the touch cache, and writes the deal ids
  193. that must be re-pulled. A deal is skipped only when it is already cached, its
  194. stage has not changed since the cache was written, and `_reusable` says nothing
  195. more can have happened to it. Everything else is pulled.
  196. """
  197. deals = []
  198. for path in args.deals:
  199. deals += json.loads(Path(path).read_text(encoding="utf-8")).get("deals", [])
  200. cache = _load_touch_cache()
  201. prev_unresolved = set()
  202. for f in sorted(OUT_DIR.glob("outreach-unresolved_*.json"))[-1:]:
  203. try:
  204. prev_unresolved = {str(x["deal_id"]) for x in
  205. json.loads(f.read_text(encoding="utf-8")).get("deals", [])}
  206. except Exception:
  207. pass
  208. need, skip, need_contact = [], 0, []
  209. for d in deals:
  210. cat = str(d.get("cat"))
  211. if cat not in AUDIT_FUNNELS:
  212. continue
  213. did = str(d.get("deal_id"))
  214. c = cache.get(did)
  215. if c and c.get("stage") == d.get("stage") and _reusable(cat, c, d.get("stage") or ""):
  216. skip += 1
  217. else:
  218. need.append(did)
  219. # Abandoned Carts titles are "Cart - 12345", so those always need a contact
  220. # lookup; everyone else only if a previous run could not place them in Plinth.
  221. if not (c or {}).get("email") and (cat == "16" or did in prev_unresolved):
  222. need_contact.append(did)
  223. OUT_DIR.mkdir(parents=True, exist_ok=True)
  224. out = OUT_DIR / f"outreach-pull-list_{TODAY}.json"
  225. out.write_text(json.dumps({"generated": NOW, "need_history": need,
  226. "need_contact": need_contact,
  227. "reused_from_cache": skip}, indent=2), encoding="utf-8")
  228. print(f"PULL-LIST: {len(need)} deals need stage history, {skip} reused from cache "
  229. f"(cache holds {len(cache)}). {len(need_contact)} need a get_record contact lookup.")
  230. print(f"wrote {out}")
  231. if not cache:
  232. print("NOTE: cache is empty: this is a first/full run, expect every deal in the list.")
  233. return 0
  234. def cmd_outreach_audit(args):
  235. """Stage F5: build the per-pipeline outreach audit workbook.
  236. Input: enumeration JSON for every audit funnel, where any deal that was
  237. re-pulled this week also carries a "history" array. Deals without "history"
  238. are served from the touch cache. Read-only on Bitrix and on the vault data;
  239. writes one workbook to 05-Outputs/ and updates the cache.
  240. """
  241. deals = []
  242. for path in args.deals:
  243. deals += json.loads(Path(path).read_text(encoding="utf-8")).get("deals", [])
  244. if not deals:
  245. sys.exit("No deals in the input JSON: nothing to audit.")
  246. wbk = Path(args.workbook) if args.workbook else latest_updated_workbook()
  247. if not wbk or not Path(wbk).exists():
  248. print("WARNING: no Drive-swap workbook found: Plinth phone/tier/LTV will be sparse.")
  249. wbk = None
  250. idx = _build_plinth_index(Path(wbk) if wbk else None)
  251. cache = _load_touch_cache()
  252. rows, fresh, reused, missing = {}, 0, 0, []
  253. for d in deals:
  254. cat = str(d.get("cat"))
  255. if cat not in AUDIT_FUNNELS:
  256. continue
  257. did = str(d.get("deal_id"))
  258. cached = cache.get(did) or {}
  259. # Precedence: this week's contact pull > whatever we cached earlier > the deal
  260. # title. The title is last on purpose: in Abandoned Carts it is "Cart - 12345",
  261. # which is useless to whoever works the sheet, while the cached contact name is
  262. # the real person.
  263. con = d.get("contact") or {}
  264. name = con.get("name") or cached.get("name") or d.get("title") or ""
  265. email = con.get("email") or cached.get("email") or ""
  266. phone = con.get("phone") or cached.get("phone") or ""
  267. if "history" in d and d["history"] is not None:
  268. t = compute_touches(cat, d["history"])
  269. fresh += 1
  270. cache[did] = {"cat": cat, "stage": d.get("stage"), "name": name,
  271. "owner": d.get("owner", ""), "email": email, "phone": phone,
  272. "first": t["first"].strftime("%Y-%m-%d %H:%M") if t["first"] else None,
  273. "last": t["last"].strftime("%Y-%m-%d %H:%M") if t["last"] else None,
  274. "last_stage": t["last_stage"], "count": t["count"],
  275. "fetched": TODAY}
  276. else:
  277. c = cache.get(did)
  278. if not c:
  279. missing.append(did)
  280. continue
  281. t = {"first": _parse_ts(c.get("first")), "last": _parse_ts(c.get("last")),
  282. "last_stage": c.get("last_stage", ""), "count": c.get("count", 0)}
  283. reused += 1
  284. ident = resolve_plinth_identity(idx, did, name, email or None)
  285. note = "TEST RECORD - exclude from metrics" if did in TEST_DEALS else ""
  286. rows.setdefault(cat, []).append({
  287. "deal": did, "name": name, "owner": d.get("owner", "") or cache.get(did, {}).get("owner", ""),
  288. "stage": audit_stage_name(cat, d.get("stage") or ""),
  289. "stage_date": _parse_ts(d.get("stage_date")), "first": t["first"], "last": t["last"],
  290. "last_stage": t["last_stage"], "count": t["count"], "value": d.get("value", ""),
  291. "plinth": ident["plinth"], "email": ident["email"] or email,
  292. "company": ident["company"], "phone": ident["phone"] or phone,
  293. "tier": ident["tier"], "ltv": ident["ltv"], "match": ident["match"], "note": note})
  294. if missing:
  295. print(f"WARNING: {len(missing)} deals had neither history nor a cache entry and were "
  296. f"DROPPED from the audit: {', '.join(missing[:20])}"
  297. f"{' …' if len(missing) > 20 else ''}")
  298. # A funnel missing from the enumeration would otherwise produce a clean-looking
  299. # workbook with no hint that part of the outreach is absent (an enumeration built
  300. # from an older prompt covers only 24/26/36). The cache remembers which funnels
  301. # were audited before, so compare against it and warn loudly.
  302. cached_cats = {}
  303. for v in cache.values():
  304. cached_cats[v.get("cat")] = cached_cats.get(v.get("cat"), 0) + 1
  305. absent = [c for c, n in cached_cats.items()
  306. if c in AUDIT_FUNNELS and n > 0 and not rows.get(c)]
  307. if absent:
  308. names = ", ".join(f"{AUDIT_FUNNELS[c]} (cat {c}, {cached_cats[c]} known deals)"
  309. for c in sorted(absent))
  310. print("=" * 78)
  311. print(f"WARNING: {len(absent)} outreach funnel(s) present in the cache are MISSING "
  312. f"from this enumeration and are absent from the workbook:")
  313. print(f" {names}")
  314. print(" The enumeration must cover all four in-scope funnels (24/26/36/16). If the "
  315. "scheduled\n prompt is the stale pre-2026-08-10 version it only pulls "
  316. "24/26/36: see scheduled-task.md.")
  317. print("=" * 78)
  318. # Deals we could not tie to a Plinth customer are the ones nobody can action.
  319. # Almost always this is a person whose Bitrix deal name does not match their Plinth
  320. # name (for example a spouse's name on a shared household mailbox) and whose deal
  321. # carries no contact email yet. Writing them out means one get_record pass next run
  322. # closes the gap: once a contact email lands in the cache it is reused every week.
  323. unresolved = [{"deal_id": r["deal"], "cat": cat, "name": r["name"], "funnel": AUDIT_FUNNELS[cat]}
  324. for cat, rs in rows.items() for r in rs
  325. if r["match"] in UNRESOLVED_MATCHES]
  326. if unresolved:
  327. OUT_DIR.mkdir(parents=True, exist_ok=True)
  328. up = OUT_DIR / f"outreach-unresolved_{TODAY}.json"
  329. up.write_text(json.dumps({"generated": NOW, "count": len(unresolved),
  330. "note": "run get_record on these deals and add "
  331. "contact{name,email,phone} to next week's enumeration",
  332. "deals": unresolved}, indent=2), encoding="utf-8")
  333. print(f"UNRESOLVED: {len(unresolved)} deals have no Plinth match: listed in {up.name}")
  334. DATA_DIR.mkdir(parents=True, exist_ok=True)
  335. TOUCH_CACHE.write_text(json.dumps(cache, indent=2, sort_keys=True), encoding="utf-8")
  336. out = write_audit_workbook(rows, fresh, reused, missing, wbk, absent)
  337. tot = sum(len(v) for v in rows.values())
  338. contacted = sum(1 for v in rows.values() for r in v
  339. if r["count"] > 0 and "TEST RECORD" not in r["note"])
  340. print(f"AUDIT: {tot} deals across {len(rows)} funnels: {contacted} with a verified touch, "
  341. f"{tot - contacted} never contacted. {fresh} histories pulled fresh, {reused} reused.")
  342. print(f"wrote {out}")
  343. return 0