audit_workbook.py
Language Python10.1 KB200 lines
"""The per-funnel outreach audit workbook."""from __future__ import annotationsimport csvimport datetime as dtfrom pathlib import Pathfrom .paths import TODAY, VAULT_ROOTfrom .stages import AUDIT_FUNNELS, AUDIT_TABAUDIT_STALE_DAYS = 90# Identity matches that do not place a deal in Plinth.UNRESOLVED_MATCHES = ("UNRESOLVED", "email only (no Plinth match)")def write_audit_workbook(rows: dict, fresh: int, reused: int, missing: list, wbk,missing_funnels: list | None = None) -> Path:outdir = VAULT_ROOT / "05-Outputs"outdir.mkdir(parents=True, exist_ok=True)stem = outdir / f"Bitrix_Pipeline_Outreach_Audit_{TODAY}"try:from openpyxl import Workbookfrom openpyxl.styles import Font, PatternFill, Alignment, Border, Sidefrom openpyxl.utils import get_column_letter as Lexcept ImportError:out = stem.with_suffix(".csv")with out.open("w", newline="", encoding="utf-8") as f:w = csv.writer(f)w.writerow(["funnel", "client", "company", "plinth_id", "email", "phone","tier", "ltv", "last_reached_out", "touch_type", "touches","first_reached_out", "current_stage", "owner", "deal_id", "matched_by", "notes"])for cat, rs in rows.items():for r in rs:w.writerow([AUDIT_FUNNELS[cat], r["name"], r["company"], r["plinth"], r["email"],r["phone"], r["tier"], r["ltv"],r["last"].isoformat(sep=" ") if r["last"] else "", r["last_stage"],r["count"], r["first"].isoformat(sep=" ") if r["first"] else "",r["stage"], r["owner"], r["deal"], r["match"], r["note"]])print("NOTE: openpyxl not installed: wrote CSV instead of xlsx.")return outAR = "Arial"T = Font(name=AR, size=16, bold=True, color="1F3864")SUB = Font(name=AR, size=10, italic=True, color="595959")H = Font(name=AR, size=10, bold=True, color="FFFFFF")HF = PatternFill("solid", fgColor="1F3864")B = Font(name=AR, size=10)BB = Font(name=AR, size=10, bold=True)thin = Side(style="thin", color="D9D9D9")BD = Border(left=thin, right=thin, top=thin, bottom=thin)NEV = PatternFill("solid", fgColor="EDEDED")STALE = PatternFill("solid", fgColor="FCE4E4")WARN = PatternFill("solid", fgColor="FFF2CC")today = dt.datetime.combine(dt.date.today(), dt.time())wb = Workbook()wb.remove(wb.active)summary = []for cat in [c for c in AUDIT_FUNNELS if c in rows]:rs = rows[cat]ws = wb.create_sheet(AUDIT_TAB[cat])contacted = [r for r in rs if r["count"] > 0 and "TEST RECORD" not in r["note"]]never = [r for r in rs if r["count"] == 0]ws["A1"] = f"{AUDIT_FUNNELS[cat]} (Bitrix funnel {cat})"ws["A1"].font = Tws["A2"] = (f"{len(rs)} deals · {len(contacted)} with a verified outbound touch · "f"{len(never)} never contacted. Sorted by last contact, newest first. "f"Built from Bitrix stage history {TODAY}.")ws["A2"].font = SUBcols = ["Client", "Company", "Plinth ID", "Email", "Phone", "Partner tier","Plinth LTV", "Last reached out", "Touch type", "Touches", "First reached out","Days since", "Current stage", "Owner", "Bitrix deal", "Deal link","How matched to Plinth", "Notes"]wd = [30, 28, 13, 32, 15, 15, 12, 17, 30, 9, 17, 10, 30, 15, 11, 46, 20, 60]if cat == "16":cols.insert(2, "Cart value")wd.insert(2, 12)for i, c in enumerate(cols, 1):x = ws.cell(row=4, column=i, value=c)x.font = Hx.fill = HFx.border = BDx.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True)for i, w in enumerate(wd, 1):ws.column_dimensions[L(i)].width = wws.freeze_panes = ws.cell(row=5, column=1)rs.sort(key=lambda r: (r["last"] is None, -(r["last"] or dt.datetime(1900, 1, 1)).timestamp()))rn = 5for r in rs:days = (today - r["last"]).days if r["last"] else Nonevals = [r["name"], r["company"], r["plinth"], r["email"], r["phone"], r["tier"], r["ltv"],r["last"], r["last_stage"], r["count"], r["first"], days, r["stage"],r["owner"], int(r["deal"]) if str(r["deal"]).isdigit() else r["deal"],f"https://meridian.bitrix24.example/crm/deal/details/{r['deal']}/",r["match"], r["note"]]if cat == "16":vals.insert(2, r.get("value", ""))for i, v in enumerate(vals, 1):c = ws.cell(row=rn, column=i, value=v)c.font = Bc.border = BDhdr = cols[i - 1]if hdr in ("Last reached out", "First reached out"):c.number_format = "mm/dd/yyyy hh:mm AM/PM"if hdr in ("Plinth LTV", "Cart value"):c.number_format = "$#,##0.00"if hdr == "Touches":c.alignment = Alignment(horizontal="center")if r["count"] == 0:for i in range(1, len(vals) + 1):ws.cell(row=rn, column=i).fill = NEVelif days is not None and days > AUDIT_STALE_DAYS:ws.cell(row=rn, column=cols.index("Days since") + 1).fill = STALEif r["note"]:ws.cell(row=rn, column=len(vals)).fill = WARNrn += 1ws.auto_filter.ref = f"A4:{L(len(cols))}{rn - 1}"lastc = max((r["last"] for r in rs if r["last"]), default=None)summary.append({"pipe": AUDIT_FUNNELS[cat], "cat": cat, "deals": len(rs),"contacted": len(contacted), "never": len(never), "last": lastc,"stale": sum(1 for r in contacted if r["last"] and (today - r["last"]).days > AUDIT_STALE_DAYS),"prem": sum(1 for r in rs if r["tier"] and r["tier"] != "not partner"),"unres": sum(1 for r in rs if r["match"] in UNRESOLVED_MATCHES)})s = wb.create_sheet("Summary", 0)s["A1"] = f"Bitrix Outreach Audit: every pipeline, current as of {TODAY}"s["A1"].font = Ts["A2"] = ("One sheet per pipeline. Each row is a client with their Plinth locating details and ""the date they were last actually reached out to. Generated by ""07-Automation/weekly_partner_pipeline.py outreach-audit.")s["A2"].font = SUBhdrs = ["Pipeline", "Cat", "Deals", "Clients contacted", "Never contacted","Partner clients", "Most recent outreach", f"Contacted >{AUDIT_STALE_DAYS} days ago","No Plinth match"]for i, c in enumerate(hdrs, 1):x = s.cell(row=4, column=i, value=c)x.font = Hx.fill = HFx.border = BDx.alignment = Alignment(wrap_text=True, vertical="center")for i, w in enumerate([34, 7, 9, 17, 16, 15, 22, 22, 14], 1):s.column_dimensions[L(i)].width = wr = 5for x in summary:for i, v in enumerate([x["pipe"], x["cat"], x["deals"], x["contacted"], x["never"],x["prem"], x["last"], x["stale"], x["unres"]], 1):c = s.cell(row=r, column=i, value=v)c.font = Bc.border = BDif i == 7:c.number_format = "mm/dd/yyyy"r += 1s.cell(row=r, column=1, value="TOTAL").font = BBfor i in (3, 4, 5, 6, 8, 9):c = s.cell(row=r, column=i, value=f"=SUM({L(i)}5:{L(i)}{r - 1})")c.font = BBfor i in range(1, 10):s.cell(row=r, column=i).border = BDs.cell(row=r, column=i).fill = PatternFill("solid", fgColor="D9E2F3")r += 2s.cell(row=r, column=1, value="How to read this").font = BBr += 1for line in ['"Last reached out" is the newest stage entry with a real outbound email, call or LinkedIn DM behind it. ''System reactions (No - Reply, No Reply Nth Email (Auto), Email Defunct, Changed Companies, Not Interested, ''Completed) are NOT contact and are excluded; see SEND_TOKENS in weekly_partner_pipeline.py.','Grey rows are clients in a pipeline who have never been contacted. Red "Days since" is older than 'f'{AUDIT_STALE_DAYS} days.','"Touches" is best-effort: same-minute workflow re-entries collapse to one send, but a bulk re-stage days ''apart still counts. Trust "Last reached out" over "Touches".','"Plinth ID" is the key for the Plinth backend. "How matched to Plinth" records which route found it, so a ''weak match is auditable rather than invisible.','Order, production and fulfillment funnels are excluded by design: customer email there is order handling, ''not outreach. Same for the inbound Leads funnel.',f'Freshness: {fresh} deal histories were re-pulled from Bitrix this run; {reused} were reused from 'f'07-Automation/data/outreach_touch_cache.json (terminal stage, unchanged since last run).',f'Plinth identity resolved against: {Path(wbk).name if wbk else "NO WORKBOOK FOUND"}.',]:c = s.cell(row=r, column=1, value="- " + line)c.font = Br += 1if missing:r += 1c = s.cell(row=r, column=1, value=f"- DROPPED: {len(missing)} deals had no history and no cache entry: "f"{', '.join(missing[:30])}")c.font = BBc.fill = WARNif missing_funnels:r += 1c = s.cell(row=r, column=1,value="- INCOMPLETE RUN: these outreach funnels were NOT in this run's enumeration and are ""missing from this workbook entirely: "+ ", ".join(f"{AUDIT_FUNNELS[x]} (cat {x})" for x in sorted(missing_funnels))+ ". Do not read this workbook as a full picture.")c.font = BBc.fill = PatternFill("solid", fgColor="FFC7CE")out = stem.with_suffix(".xlsx")wb.save(out)return out