audit.py
Language Python18.3 KB375 lines
"""outreach-pull-list and outreach-audit: who was actually contacted, and when."""from __future__ import annotationsimport datetime as dtimport jsonimport sysfrom pathlib import Pathfrom .audit_workbook import UNRESOLVED_MATCHES, write_audit_workbookfrom .census import norm_keyfrom .paths import DATA_DIR, NOW, OUT_DIR, TODAY, TOUCH_CACHE, XML_MAPfrom .roster_workbook import ALL_TAB, PARTNER_TAB, latest_updated_workbookfrom .stages import AUDIT_FUNNELS, SEND_TOKENS, TEST_DEALS, audit_stage_name, is_terminalSAME_SEND_GAP_MIN = 2 # two entries into the same stage this close = one senddef _reusable(cat: str, cached: dict, stage_id: str) -> bool:"""Can this deal's cached touch history be reused instead of re-pulled?Two cases, both requiring the current stage to be unchanged since the cache waswritten (the caller checks that):1. Terminal stage: the deal is closed, nothing more can happen to it.2. Zero recorded touches: the deal has never entered a send stage, so it cannotbe sitting in one now, and a send would have moved it. This is what keeps thebig parked cohorts cheap: Winback "Client", Abandoned Carts "New AbandonedCart", cat 24 "LinkedIn Research & Connection", cat 36 "Active Check-In List"hold most of the deals, and nobody has touched them.Residual risk in both cases is the same: a deal that entered a send stage andreturned to its original stage between two runs would be missed. It self-correctsthe next time its stage actually changes. Deals with touches sitting in a sendstage are always re-pulled, because a repeat send re-enters the same stage andthat is a real pattern (cat 26 Follow-Up Email)."""if is_terminal(cat, stage_id):return Truereturn int(cached.get("count") or 0) == 0def _parse_ts(s):if not s or str(s).strip().upper() in ("", "NONE", "NULL"):return Nones = str(s).strip().replace("T", " ")[:16]try:return dt.datetime.strptime(s, "%Y-%m-%d %H:%M")except ValueError:try:return dt.datetime.strptime(s[:10], "%Y-%m-%d")except ValueError:return Nonedef compute_touches(cat: str, history: list) -> dict:"""Reduce a raw stage history to verified outbound contact.history: [{"stage": "C36:EXECUTING", "at": "2026-07-29 16:40"}, ...] any order.Returns {first, last, last_stage, count} with datetimes (or None).Only SEND_TOKENS count. Repeat entries into the SAME stage withinSAME_SEND_GAP_MIN minutes collapse to one send: Bitrix workflows re-enter astage several times in the same minute and that is one email, not four.Re-entries days apart DO count separately; the `count` is therefore abest-effort figure while `last` is the reliable one."""sends = []for h in history or []:stage = h.get("stage") or ""# A deal can be moved between funnels, so its history legitimately contains# stages from other categories (e.g. an Abandoned Cart that converted and went# on through Pre-Production/Production carries C18:* and C20:* entries).# Token-only matching would then count C20:PREPARATION as an abandoned-cart# email, because "PREPARATION" is a send token in cat 16. Require the category# prefix to match the funnel we are auditing; a bare token with no prefix is# assumed to belong to this funnel.if ":" in stage:h_cat, token = stage.split(":", 1)h_cat = h_cat.lstrip("Cc")if h_cat != cat:continueelse:token = stageif token not in SEND_TOKENS.get(cat, set()):continuets = _parse_ts(h.get("at"))if ts is None:continuesends.append((ts, token, stage))sends.sort()kept = []for ts, token, stage in sends:if kept and kept[-1][1] == token and (ts - kept[-1][0]).total_seconds() <= SAME_SEND_GAP_MIN * 60:continuekept.append((ts, token, stage))if not kept:return {"first": None, "last": None, "last_stage": "", "count": 0}return {"first": kept[0][0], "last": kept[-1][0],"last_stage": audit_stage_name(cat, kept[-1][2]), "count": len(kept)}def _load_touch_cache() -> dict:if TOUCH_CACHE.exists():try:return json.loads(TOUCH_CACHE.read_text(encoding="utf-8"))except Exception as e:print(f"WARNING: touch cache unreadable ({e}): treating every deal as new.")return {}def _build_plinth_index(workbook: Path | None):"""Identity lookup for the roster owner: Plinth id, email, phone, company, tier, LTV.Three sources, most specific first:1. the XML census -> bitrix_deal_id -> plinth_id (exact, by deal)2. partner tab of the Drive-swap workbook -> phone/company/tier/LTV3. All Accounts of the same workbook = the full Plinth customer list (every account),which is the only place non-partner people (cart guests, dormant winback)can be found at all."""import xml.etree.ElementTree as ETby_deal, prem, prem_email, prem_name, plinth_email, plinth_name = {}, {}, {}, {}, {}, {}if XML_MAP.exists():root = ET.parse(XML_MAP).getroot()for c in root.findall("client"):rec = {"plinth": (c.findtext("plinth_id") or "").strip(),"email": (c.findtext("email") or "").strip(),"company": (c.findtext("company") or "").strip(),"tier": (c.findtext("user_group") or "").strip()}for blk in c.iter("pipeline"):d = (blk.findtext("bitrix_deal_id") or "").strip()if d:by_deal[d] = recif workbook and workbook.exists():try:from openpyxl import load_workbookwb = load_workbook(workbook, read_only=True, data_only=True)if PARTNER_TAB in wb.sheetnames:for i, row in enumerate(wb[PARTNER_TAB].iter_rows(values_only=True)):if i == 0 or not row or row[0] is None:continuesid = str(int(row[0])) if isinstance(row[0], (int, float)) else str(row[0]).strip()rec = {"plinth": sid, "email": row[3], "company": row[5], "phone": row[6],"tier": row[10] or row[9], "ltv": row[13]}prem[sid] = recfor e in (row[3], row[4]):if e and "@" in str(e):prem_email.setdefault(str(e).strip().lower(), rec)n = norm_key(f"{row[1]}{row[2]}")if n:prem_name.setdefault(n, rec)if ALL_TAB in wb.sheetnames:for i, row in enumerate(wb[ALL_TAB].iter_rows(values_only=True)):if i == 0 or not row or row[0] is None:continuesid = str(int(row[0])) if isinstance(row[0], (int, float)) else str(row[0]).strip()rec = {"plinth": sid, "email": row[3], "ltv": row[4]}if row[3] and "@" in str(row[3]):plinth_email.setdefault(str(row[3]).strip().lower(), rec)n = norm_key(f"{row[1]}{row[2]}")if n:plinth_name.setdefault(n, rec)wb.close()except ImportError:print("NOTE: openpyxl missing: Plinth identity columns will be blank.")return by_deal, prem, prem_email, prem_name, plinth_email, plinth_namedef resolve_plinth_identity(idx, deal_id, name, email=None) -> dict:by_deal, prem, prem_email, prem_name, plinth_email, plinth_name = idxout = {"plinth": "", "email": email or "", "company": "", "phone": "","tier": "", "ltv": None, "match": "UNRESOLVED"}c = by_deal.get(str(deal_id))if c and c["plinth"]:out.update(plinth=c["plinth"], email=c["email"] or out["email"],company=c["company"], tier=c["tier"], match="deal->census")p = prem.get(c["plinth"])if p:out["phone"] = p.get("phone") or ""out["ltv"] = p.get("ltv")out["company"] = out["company"] or (p.get("company") or "")out["email"] = out["email"] or (p.get("email") or "")out["tier"] = out["tier"] or (p.get("tier") or "")return outif email:e = str(email).strip().lower()p = prem_email.get(e)if p:return {"plinth": p["plinth"], "email": p.get("email") or email,"company": p.get("company") or "", "phone": p.get("phone") or "","tier": p.get("tier") or "", "ltv": p.get("ltv"),"match": "email->partner"}o = plinth_email.get(e)if o:return {"plinth": o["plinth"], "email": o.get("email") or email, "company": "","phone": "", "tier": "not partner", "ltv": o.get("ltv"),"match": "email->Plinth census"}n = norm_key(name)if n and n in prem_name:p = prem_name[n]return {"plinth": p["plinth"], "email": p.get("email") or "", "company": p.get("company") or "","phone": p.get("phone") or "", "tier": p.get("tier") or "","ltv": p.get("ltv"), "match": "name->partner"}if n and n in plinth_name:o = plinth_name[n]return {"plinth": o["plinth"], "email": o.get("email") or "", "company": "", "phone": "","tier": "not partner", "ltv": o.get("ltv"), "match": "name->Plinth census"}if email:out["match"] = "email only (no Plinth match)"return outdef cmd_outreach_pull_list(args):"""Decide which deals actually need a fresh stage-history pull this week.Reads the weekly enumeration JSON and the touch cache, and writes the deal idsthat must be re-pulled. A deal is skipped only when it is already cached, itsstage has not changed since the cache was written, and `_reusable` says nothingmore can have happened to it. Everything else is pulled."""deals = []for path in args.deals:deals += json.loads(Path(path).read_text(encoding="utf-8")).get("deals", [])cache = _load_touch_cache()prev_unresolved = set()for f in sorted(OUT_DIR.glob("outreach-unresolved_*.json"))[-1:]:try:prev_unresolved = {str(x["deal_id"]) for x injson.loads(f.read_text(encoding="utf-8")).get("deals", [])}except Exception:passneed, skip, need_contact = [], 0, []for d in deals:cat = str(d.get("cat"))if cat not in AUDIT_FUNNELS:continuedid = str(d.get("deal_id"))c = cache.get(did)if c and c.get("stage") == d.get("stage") and _reusable(cat, c, d.get("stage") or ""):skip += 1else:need.append(did)# Abandoned Carts titles are "Cart - 12345", so those always need a contact# lookup; everyone else only if a previous run could not place them in Plinth.if not (c or {}).get("email") and (cat == "16" or did in prev_unresolved):need_contact.append(did)OUT_DIR.mkdir(parents=True, exist_ok=True)out = OUT_DIR / f"outreach-pull-list_{TODAY}.json"out.write_text(json.dumps({"generated": NOW, "need_history": need,"need_contact": need_contact,"reused_from_cache": skip}, indent=2), encoding="utf-8")print(f"PULL-LIST: {len(need)} deals need stage history, {skip} reused from cache "f"(cache holds {len(cache)}). {len(need_contact)} need a get_record contact lookup.")print(f"wrote {out}")if not cache:print("NOTE: cache is empty: this is a first/full run, expect every deal in the list.")return 0def cmd_outreach_audit(args):"""Stage F5: build the per-pipeline outreach audit workbook.Input: enumeration JSON for every audit funnel, where any deal that wasre-pulled this week also carries a "history" array. Deals without "history"are served from the touch cache. Read-only on Bitrix and on the vault data;writes one workbook to 05-Outputs/ and updates the cache."""deals = []for path in args.deals:deals += json.loads(Path(path).read_text(encoding="utf-8")).get("deals", [])if not deals:sys.exit("No deals in the input JSON: nothing to audit.")wbk = Path(args.workbook) if args.workbook else latest_updated_workbook()if not wbk or not Path(wbk).exists():print("WARNING: no Drive-swap workbook found: Plinth phone/tier/LTV will be sparse.")wbk = Noneidx = _build_plinth_index(Path(wbk) if wbk else None)cache = _load_touch_cache()rows, fresh, reused, missing = {}, 0, 0, []for d in deals:cat = str(d.get("cat"))if cat not in AUDIT_FUNNELS:continuedid = str(d.get("deal_id"))cached = cache.get(did) or {}# Precedence: this week's contact pull > whatever we cached earlier > the deal# title. The title is last on purpose: in Abandoned Carts it is "Cart - 12345",# which is useless to whoever works the sheet, while the cached contact name is# the real person.con = d.get("contact") or {}name = con.get("name") or cached.get("name") or d.get("title") or ""email = con.get("email") or cached.get("email") or ""phone = con.get("phone") or cached.get("phone") or ""if "history" in d and d["history"] is not None:t = compute_touches(cat, d["history"])fresh += 1cache[did] = {"cat": cat, "stage": d.get("stage"), "name": name,"owner": d.get("owner", ""), "email": email, "phone": phone,"first": t["first"].strftime("%Y-%m-%d %H:%M") if t["first"] else None,"last": t["last"].strftime("%Y-%m-%d %H:%M") if t["last"] else None,"last_stage": t["last_stage"], "count": t["count"],"fetched": TODAY}else:c = cache.get(did)if not c:missing.append(did)continuet = {"first": _parse_ts(c.get("first")), "last": _parse_ts(c.get("last")),"last_stage": c.get("last_stage", ""), "count": c.get("count", 0)}reused += 1ident = resolve_plinth_identity(idx, did, name, email or None)note = "TEST RECORD - exclude from metrics" if did in TEST_DEALS else ""rows.setdefault(cat, []).append({"deal": did, "name": name, "owner": d.get("owner", "") or cache.get(did, {}).get("owner", ""),"stage": audit_stage_name(cat, d.get("stage") or ""),"stage_date": _parse_ts(d.get("stage_date")), "first": t["first"], "last": t["last"],"last_stage": t["last_stage"], "count": t["count"], "value": d.get("value", ""),"plinth": ident["plinth"], "email": ident["email"] or email,"company": ident["company"], "phone": ident["phone"] or phone,"tier": ident["tier"], "ltv": ident["ltv"], "match": ident["match"], "note": note})if missing:print(f"WARNING: {len(missing)} deals had neither history nor a cache entry and were "f"DROPPED from the audit: {', '.join(missing[:20])}"f"{' …' if len(missing) > 20 else ''}")# A funnel missing from the enumeration would otherwise produce a clean-looking# workbook with no hint that part of the outreach is absent (an enumeration built# from an older prompt covers only 24/26/36). The cache remembers which funnels# were audited before, so compare against it and warn loudly.cached_cats = {}for v in cache.values():cached_cats[v.get("cat")] = cached_cats.get(v.get("cat"), 0) + 1absent = [c for c, n in cached_cats.items()if c in AUDIT_FUNNELS and n > 0 and not rows.get(c)]if absent:names = ", ".join(f"{AUDIT_FUNNELS[c]} (cat {c}, {cached_cats[c]} known deals)"for c in sorted(absent))print("=" * 78)print(f"WARNING: {len(absent)} outreach funnel(s) present in the cache are MISSING "f"from this enumeration and are absent from the workbook:")print(f" {names}")print(" The enumeration must cover all four in-scope funnels (24/26/36/16). If the ""scheduled\n prompt is the stale pre-2026-08-10 version it only pulls ""24/26/36: see scheduled-task.md.")print("=" * 78)# Deals we could not tie to a Plinth customer are the ones nobody can action.# Almost always this is a person whose Bitrix deal name does not match their Plinth# name (for example a spouse's name on a shared household mailbox) and whose deal# carries no contact email yet. Writing them out means one get_record pass next run# closes the gap: once a contact email lands in the cache it is reused every week.unresolved = [{"deal_id": r["deal"], "cat": cat, "name": r["name"], "funnel": AUDIT_FUNNELS[cat]}for cat, rs in rows.items() for r in rsif r["match"] in UNRESOLVED_MATCHES]if unresolved:OUT_DIR.mkdir(parents=True, exist_ok=True)up = OUT_DIR / f"outreach-unresolved_{TODAY}.json"up.write_text(json.dumps({"generated": NOW, "count": len(unresolved),"note": "run get_record on these deals and add ""contact{name,email,phone} to next week's enumeration","deals": unresolved}, indent=2), encoding="utf-8")print(f"UNRESOLVED: {len(unresolved)} deals have no Plinth match: listed in {up.name}")DATA_DIR.mkdir(parents=True, exist_ok=True)TOUCH_CACHE.write_text(json.dumps(cache, indent=2, sort_keys=True), encoding="utf-8")out = write_audit_workbook(rows, fresh, reused, missing, wbk, absent)tot = sum(len(v) for v in rows.values())contacted = sum(1 for v in rows.values() for r in vif r["count"] > 0 and "TEST RECORD" not in r["note"])print(f"AUDIT: {tot} deals across {len(rows)} funnels: {contacted} with a verified touch, "f"{tot - contacted} never contacted. {fresh} histories pulled fresh, {reused} reused.")print(f"wrote {out}")return 0