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

audit_workbook.py

Language Python10.1 KB200 lines

07-Automation/partner_pipeline/audit_workbook.py

  1. """The per-funnel outreach audit workbook."""
  2. from __future__ import annotations
  3. import csv
  4. import datetime as dt
  5. from pathlib import Path
  6. from .paths import TODAY, VAULT_ROOT
  7. from .stages import AUDIT_FUNNELS, AUDIT_TAB
  8. AUDIT_STALE_DAYS = 90
  9. # Identity matches that do not place a deal in Plinth.
  10. UNRESOLVED_MATCHES = ("UNRESOLVED", "email only (no Plinth match)")
  11. def write_audit_workbook(rows: dict, fresh: int, reused: int, missing: list, wbk,
  12. missing_funnels: list | None = None) -> Path:
  13. outdir = VAULT_ROOT / "05-Outputs"
  14. outdir.mkdir(parents=True, exist_ok=True)
  15. stem = outdir / f"Bitrix_Pipeline_Outreach_Audit_{TODAY}"
  16. try:
  17. from openpyxl import Workbook
  18. from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
  19. from openpyxl.utils import get_column_letter as L
  20. except ImportError:
  21. out = stem.with_suffix(".csv")
  22. with out.open("w", newline="", encoding="utf-8") as f:
  23. w = csv.writer(f)
  24. w.writerow(["funnel", "client", "company", "plinth_id", "email", "phone",
  25. "tier", "ltv", "last_reached_out", "touch_type", "touches",
  26. "first_reached_out", "current_stage", "owner", "deal_id", "matched_by", "notes"])
  27. for cat, rs in rows.items():
  28. for r in rs:
  29. w.writerow([AUDIT_FUNNELS[cat], r["name"], r["company"], r["plinth"], r["email"],
  30. r["phone"], r["tier"], r["ltv"],
  31. r["last"].isoformat(sep=" ") if r["last"] else "", r["last_stage"],
  32. r["count"], r["first"].isoformat(sep=" ") if r["first"] else "",
  33. r["stage"], r["owner"], r["deal"], r["match"], r["note"]])
  34. print("NOTE: openpyxl not installed: wrote CSV instead of xlsx.")
  35. return out
  36. AR = "Arial"
  37. T = Font(name=AR, size=16, bold=True, color="1F3864")
  38. SUB = Font(name=AR, size=10, italic=True, color="595959")
  39. H = Font(name=AR, size=10, bold=True, color="FFFFFF")
  40. HF = PatternFill("solid", fgColor="1F3864")
  41. B = Font(name=AR, size=10)
  42. BB = Font(name=AR, size=10, bold=True)
  43. thin = Side(style="thin", color="D9D9D9")
  44. BD = Border(left=thin, right=thin, top=thin, bottom=thin)
  45. NEV = PatternFill("solid", fgColor="EDEDED")
  46. STALE = PatternFill("solid", fgColor="FCE4E4")
  47. WARN = PatternFill("solid", fgColor="FFF2CC")
  48. today = dt.datetime.combine(dt.date.today(), dt.time())
  49. wb = Workbook()
  50. wb.remove(wb.active)
  51. summary = []
  52. for cat in [c for c in AUDIT_FUNNELS if c in rows]:
  53. rs = rows[cat]
  54. ws = wb.create_sheet(AUDIT_TAB[cat])
  55. contacted = [r for r in rs if r["count"] > 0 and "TEST RECORD" not in r["note"]]
  56. never = [r for r in rs if r["count"] == 0]
  57. ws["A1"] = f"{AUDIT_FUNNELS[cat]} (Bitrix funnel {cat})"
  58. ws["A1"].font = T
  59. ws["A2"] = (f"{len(rs)} deals · {len(contacted)} with a verified outbound touch · "
  60. f"{len(never)} never contacted. Sorted by last contact, newest first. "
  61. f"Built from Bitrix stage history {TODAY}.")
  62. ws["A2"].font = SUB
  63. cols = ["Client", "Company", "Plinth ID", "Email", "Phone", "Partner tier",
  64. "Plinth LTV", "Last reached out", "Touch type", "Touches", "First reached out",
  65. "Days since", "Current stage", "Owner", "Bitrix deal", "Deal link",
  66. "How matched to Plinth", "Notes"]
  67. wd = [30, 28, 13, 32, 15, 15, 12, 17, 30, 9, 17, 10, 30, 15, 11, 46, 20, 60]
  68. if cat == "16":
  69. cols.insert(2, "Cart value")
  70. wd.insert(2, 12)
  71. for i, c in enumerate(cols, 1):
  72. x = ws.cell(row=4, column=i, value=c)
  73. x.font = H
  74. x.fill = HF
  75. x.border = BD
  76. x.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True)
  77. for i, w in enumerate(wd, 1):
  78. ws.column_dimensions[L(i)].width = w
  79. ws.freeze_panes = ws.cell(row=5, column=1)
  80. rs.sort(key=lambda r: (r["last"] is None, -(r["last"] or dt.datetime(1900, 1, 1)).timestamp()))
  81. rn = 5
  82. for r in rs:
  83. days = (today - r["last"]).days if r["last"] else None
  84. vals = [r["name"], r["company"], r["plinth"], r["email"], r["phone"], r["tier"], r["ltv"],
  85. r["last"], r["last_stage"], r["count"], r["first"], days, r["stage"],
  86. r["owner"], int(r["deal"]) if str(r["deal"]).isdigit() else r["deal"],
  87. f"https://meridian.bitrix24.example/crm/deal/details/{r['deal']}/",
  88. r["match"], r["note"]]
  89. if cat == "16":
  90. vals.insert(2, r.get("value", ""))
  91. for i, v in enumerate(vals, 1):
  92. c = ws.cell(row=rn, column=i, value=v)
  93. c.font = B
  94. c.border = BD
  95. hdr = cols[i - 1]
  96. if hdr in ("Last reached out", "First reached out"):
  97. c.number_format = "mm/dd/yyyy hh:mm AM/PM"
  98. if hdr in ("Plinth LTV", "Cart value"):
  99. c.number_format = "$#,##0.00"
  100. if hdr == "Touches":
  101. c.alignment = Alignment(horizontal="center")
  102. if r["count"] == 0:
  103. for i in range(1, len(vals) + 1):
  104. ws.cell(row=rn, column=i).fill = NEV
  105. elif days is not None and days > AUDIT_STALE_DAYS:
  106. ws.cell(row=rn, column=cols.index("Days since") + 1).fill = STALE
  107. if r["note"]:
  108. ws.cell(row=rn, column=len(vals)).fill = WARN
  109. rn += 1
  110. ws.auto_filter.ref = f"A4:{L(len(cols))}{rn - 1}"
  111. lastc = max((r["last"] for r in rs if r["last"]), default=None)
  112. summary.append({"pipe": AUDIT_FUNNELS[cat], "cat": cat, "deals": len(rs),
  113. "contacted": len(contacted), "never": len(never), "last": lastc,
  114. "stale": sum(1 for r in contacted if r["last"] and (today - r["last"]).days > AUDIT_STALE_DAYS),
  115. "prem": sum(1 for r in rs if r["tier"] and r["tier"] != "not partner"),
  116. "unres": sum(1 for r in rs if r["match"] in UNRESOLVED_MATCHES)})
  117. s = wb.create_sheet("Summary", 0)
  118. s["A1"] = f"Bitrix Outreach Audit: every pipeline, current as of {TODAY}"
  119. s["A1"].font = T
  120. s["A2"] = ("One sheet per pipeline. Each row is a client with their Plinth locating details and "
  121. "the date they were last actually reached out to. Generated by "
  122. "07-Automation/weekly_partner_pipeline.py outreach-audit.")
  123. s["A2"].font = SUB
  124. hdrs = ["Pipeline", "Cat", "Deals", "Clients contacted", "Never contacted",
  125. "Partner clients", "Most recent outreach", f"Contacted >{AUDIT_STALE_DAYS} days ago",
  126. "No Plinth match"]
  127. for i, c in enumerate(hdrs, 1):
  128. x = s.cell(row=4, column=i, value=c)
  129. x.font = H
  130. x.fill = HF
  131. x.border = BD
  132. x.alignment = Alignment(wrap_text=True, vertical="center")
  133. for i, w in enumerate([34, 7, 9, 17, 16, 15, 22, 22, 14], 1):
  134. s.column_dimensions[L(i)].width = w
  135. r = 5
  136. for x in summary:
  137. for i, v in enumerate([x["pipe"], x["cat"], x["deals"], x["contacted"], x["never"],
  138. x["prem"], x["last"], x["stale"], x["unres"]], 1):
  139. c = s.cell(row=r, column=i, value=v)
  140. c.font = B
  141. c.border = BD
  142. if i == 7:
  143. c.number_format = "mm/dd/yyyy"
  144. r += 1
  145. s.cell(row=r, column=1, value="TOTAL").font = BB
  146. for i in (3, 4, 5, 6, 8, 9):
  147. c = s.cell(row=r, column=i, value=f"=SUM({L(i)}5:{L(i)}{r - 1})")
  148. c.font = BB
  149. for i in range(1, 10):
  150. s.cell(row=r, column=i).border = BD
  151. s.cell(row=r, column=i).fill = PatternFill("solid", fgColor="D9E2F3")
  152. r += 2
  153. s.cell(row=r, column=1, value="How to read this").font = BB
  154. r += 1
  155. for line in [
  156. '"Last reached out" is the newest stage entry with a real outbound email, call or LinkedIn DM behind it. '
  157. 'System reactions (No - Reply, No Reply Nth Email (Auto), Email Defunct, Changed Companies, Not Interested, '
  158. 'Completed) are NOT contact and are excluded; see SEND_TOKENS in weekly_partner_pipeline.py.',
  159. 'Grey rows are clients in a pipeline who have never been contacted. Red "Days since" is older than '
  160. f'{AUDIT_STALE_DAYS} days.',
  161. '"Touches" is best-effort: same-minute workflow re-entries collapse to one send, but a bulk re-stage days '
  162. 'apart still counts. Trust "Last reached out" over "Touches".',
  163. '"Plinth ID" is the key for the Plinth backend. "How matched to Plinth" records which route found it, so a '
  164. 'weak match is auditable rather than invisible.',
  165. 'Order, production and fulfillment funnels are excluded by design: customer email there is order handling, '
  166. 'not outreach. Same for the inbound Leads funnel.',
  167. f'Freshness: {fresh} deal histories were re-pulled from Bitrix this run; {reused} were reused from '
  168. f'07-Automation/data/outreach_touch_cache.json (terminal stage, unchanged since last run).',
  169. f'Plinth identity resolved against: {Path(wbk).name if wbk else "NO WORKBOOK FOUND"}.',
  170. ]:
  171. c = s.cell(row=r, column=1, value="- " + line)
  172. c.font = B
  173. r += 1
  174. if missing:
  175. r += 1
  176. c = s.cell(row=r, column=1, value=f"- DROPPED: {len(missing)} deals had no history and no cache entry: "
  177. f"{', '.join(missing[:30])}")
  178. c.font = BB
  179. c.fill = WARN
  180. if missing_funnels:
  181. r += 1
  182. c = s.cell(row=r, column=1,
  183. value="- INCOMPLETE RUN: these outreach funnels were NOT in this run's enumeration and are "
  184. "missing from this workbook entirely: "
  185. + ", ".join(f"{AUDIT_FUNNELS[x]} (cat {x})" for x in sorted(missing_funnels))
  186. + ". Do not read this workbook as a full picture.")
  187. c.font = BB
  188. c.fill = PatternFill("solid", fgColor="FFC7CE")
  189. out = stem.with_suffix(".xlsx")
  190. wb.save(out)
  191. return out