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

stages.py

Language Python7.8 KB157 lines

07-Automation/partner_pipeline/stages.py

  1. """Bitrix funnels and stage tokens: names, sends, terminal stages."""
  2. from __future__ import annotations
  3. # Stage names are keyed by stage token, never by display name: display names get
  4. # renamed in Bitrix and the same label can mean different things in different funnels.
  5. # The three funnels the census maps (reconcile-deals, route).
  6. FUNNEL_KEYS = {"24": "partner_outreach", "26": "winback", "36": "quarterly_checkin"}
  7. FUNNEL_DISPLAY = {"24": "Partner Tier Upgrade", "26": "Winback",
  8. "36": "Quarterly Check-In"}
  9. STAGE_NAMES = {
  10. "24": {"UC_2YNXWE": "LinkedIn Research & Connection", "NEW": "Update Info Plinth",
  11. "UC_6H9C8U": "TIER CHANGE EMAIL SENT", "UC_5DTN59": "Need Artwork/Mockup",
  12. "PREPAYMENT_INVOIC": "Gift Mockup Sent", "EXECUTING": "No Reply Mockup",
  13. "UC_PT8D2U": "Client Approved", "FINAL_INVOICE": "Gift Sent",
  14. "UC_HWT78S": "Gift Delivered",
  15. "UC_L2S2TK": "Potential Partners (PP) - HAZEL ONLY",
  16. "UC_2EYUH4": "PP LinkedIn Connection - HAZEL ONLY",
  17. "UC_2DE6NT": "PP Email Reachout Sent - HAZEL ONLY",
  18. "UC_RCNECL": "Client Replied", "UC_N5U7JC": "Qtly Touch-Point Scheduled",
  19. "WON": "Deal won", "LOSE": "Not Interested"},
  20. "26": {"UC_JUUNES": "Client", "UC_L2JMHY": "Activated",
  21. "NEW": "LinkedIn Research & Connection",
  22. "PREPARATION": "First Email", "UC_K9M6LT": "No Reply 1st Email (Auto)",
  23. "PREPAYMENT_INVOIC": "Follow-Up Email", "UC_D6LU8W": "No Reply 2nd Email (Auto)",
  24. "EXECUTING": "LinkedIn DM", "FINAL_INVOICE": "Pre-Call Email",
  25. "UC_BMQAKY": "Call Booked / Voicemail", "UC_EPG639": "Changed Companies",
  26. "UC_YUE3NB": "Placed Order / Quote", "WON": "Winback Outreach Completed",
  27. "LOSE": "No Reply from Customer"},
  28. "36": {"NEW": "Active Check-In List", "UC_LXCMAV": "Dormant",
  29. "PREPARATION": "LinkedIn Research & Connection",
  30. "PREPAYMENT_INVOIC": "First Email Sent", "UC_X25FL3": "Call Booked",
  31. "UC_7XQR9H": "No - Reply", "EXECUTING": "Second Email Sent",
  32. "UC_32CNNC": "Email Defunct", "FINAL_INVOICE": "Changed Companies/Jobs",
  33. "WON": "Quarterly Completed", "LOSE": "Never Replied", "1": "Not Interested"},
  34. }
  35. # Check a stage rename against live pipeline output before editing this table: a name
  36. # recorded against the wrong token is written onto every census block in that stage
  37. # at the next reconcile-deals run (C36:NEW is "Active Check-In List", C36:UC_LXCMAV is
  38. # "Dormant"). A token missing here resolves to the raw token, with a warning.
  39. # "Not Interested"-class closures are opt-outs: full email suppression, see DECISIONS.md.
  40. OPTOUT_STAGES = {"Not Interested"}
  41. # The outreach audit (Stage F5) covers the funnels that carry client outreach: the three
  42. # the census maps plus Abandoned Carts. Order, production and fulfillment funnels are
  43. # left out because customer email there is order handling, not outreach, and so are
  44. # funnels that send email for other reasons (delivery follow-up, cold outreach): this
  45. # is a client-outreach tracker, not an inventory of every funnel that sends email.
  46. # The scope is a recorded decision (DECISIONS.md); change it there first.
  47. AUDIT_FUNNELS = {
  48. "24": "Partner Tier Upgrade",
  49. "26": "Winback",
  50. "36": "Quarterly Check-In Flow",
  51. "16": "Abandoned Carts",
  52. }
  53. AUDIT_TAB = {
  54. "24": "Partner Tier Upgrade (24)", "26": "Winback (26)",
  55. "36": "Quarterly Check-In (36)", "16": "Abandoned Carts (16)",
  56. }
  57. # Stage names for the audit-only funnel (16). Kept separate from STAGE_NAMES so
  58. # reconcile-deals' cat 24/26/36 behaviour is untouched.
  59. AUDIT_STAGE_NAMES = {
  60. "16": {"NEW": "New Abandoned Cart", "UC_2AAJ7Q": "Guests",
  61. "PREPARATION": "First Email Sent", "UC_6V2M93": "No Reply 1st Email",
  62. "PREPAYMENT_INVOIC": "Follow-Up Email Sent", "EXECUTING": "No Reply 2nd Email",
  63. "WON": "Converted", "LOSE": "Not Converted / Closed"},
  64. }
  65. # Retired stages were deleted from the Bitrix funnel config, so the API returns a bare
  66. # token with no name and a naive audit silently drops them. They were identified by
  67. # matching each stage-entry timestamp against the deal's outbound email timeline (see
  68. # DECISIONS.md). Do not remove these: stage histories still reference them, and without
  69. # them clients who were emailed read as never contacted.
  70. RETIRED_STAGE_NAMES = {
  71. "24": {"PREPARATION": "Email Sent (retired)",
  72. "UC_CEAGAV": "2nd Reachout - call + email (retired)",
  73. "UC_V6JARB": "3rd Reachout - follow-up email (retired)",
  74. "UC_N4JCMW": "Bulk re-stage, no send (retired)"},
  75. "26": {"UC_PMLDJW": "Feedback Survey Email (retired)",
  76. "UC_GDHDJR": "Feedback Survey Reminder Email (retired)"},
  77. }
  78. for _c, _m in RETIRED_STAGE_NAMES.items():
  79. STAGE_NAMES.setdefault(_c, {})
  80. for _t, _n in _m.items():
  81. STAGE_NAMES[_c].setdefault(_t, _n) # setdefault: never shadow a live stage
  82. # Stage tokens where a real outbound email, call or LinkedIn DM sits behind the stage
  83. # move. Everything else is a system reaction (No - Reply, No Reply Nth Email (Auto),
  84. # Email Defunct, Changed Companies, Not Interested, Completed) and must not count as
  85. # contact.
  86. #
  87. # Two traps, both checked against deal timelines:
  88. # * cat 26 FINAL_INVOICE is called "Pre-Call Email" and is a real send. A funnel
  89. # outside this audit's scope uses that same label for its creation stage, which
  90. # sends nothing, so the rule is keyed on the token, never on the stage name.
  91. # * cat 26 UC_L2JMHY ("Activated") is excluded on purpose. It is the intake bucket
  92. # clients land in when they are made active in Winback: the funnel is too big to
  93. # work in one quarter, so activating is how a slice gets picked up for this quarter.
  94. # Nothing is sent on that move; the send happens further down the funnel. Adding it
  95. # here would read every activation as outreach.
  96. SEND_TOKENS = {
  97. "24": {"UC_6H9C8U", "PREPAYMENT_INVOIC", "FINAL_INVOICE", "UC_2DE6NT",
  98. "PREPARATION", "UC_CEAGAV", "UC_V6JARB"}, # not UC_N4JCMW: a bulk re-stage, no send
  99. "26": {"PREPARATION", "PREPAYMENT_INVOIC", "EXECUTING", "FINAL_INVOICE",
  100. "UC_BMQAKY", "UC_PMLDJW", "UC_GDHDJR"},
  101. "36": {"PREPAYMENT_INVOIC", "EXECUTING"},
  102. "16": {"PREPARATION", "PREPAYMENT_INVOIC"},
  103. }
  104. # Terminal stages: once a deal is here and its stage has not changed since the last
  105. # run, its touch history is treated as frozen and reused from the cache instead of
  106. # being re-pulled. This is what makes the audit affordable weekly.
  107. TERMINAL_TOKENS = {"WON", "LOSE"}
  108. TERMINAL_EXTRA = {"36": {"1"}} # cat 36 "Not Interested" is its own token
  109. # Known test / non-client records. Flagged in the sheet, never deleted.
  110. TEST_DEALS = {"400017", "400023"}
  111. def token(stage_id: str) -> str:
  112. """The stage token of a Bitrix stage id: "C36:EXECUTING" -> "EXECUTING"."""
  113. return stage_id.split(":", 1)[1] if ":" in stage_id else stage_id
  114. def stage_status(cat: str, stage_id: str) -> tuple[str, str]:
  115. """(human stage name, block status) from a raw Bitrix stage id like C36:EXECUTING."""
  116. tok = token(stage_id)
  117. name = STAGE_NAMES.get(cat, {}).get(tok)
  118. if name is None:
  119. print(f"WARNING: unknown stage {stage_id} in cat {cat}: using raw token. "
  120. "Add it to STAGE_NAMES.")
  121. name = tok
  122. if tok == "WON":
  123. status = "completed"
  124. elif tok == "LOSE" or (cat == "36" and tok == "1"):
  125. status = "lost"
  126. else:
  127. status = "active"
  128. return name, status
  129. def audit_stage_name(cat: str, stage_id: str) -> str:
  130. tok = token(stage_id)
  131. for src in (STAGE_NAMES, AUDIT_STAGE_NAMES, RETIRED_STAGE_NAMES):
  132. n = src.get(cat, {}).get(tok)
  133. if n:
  134. return n
  135. return tok
  136. def is_terminal(cat: str, stage_id: str) -> bool:
  137. tok = token(stage_id)
  138. return tok in TERMINAL_TOKENS or tok in TERMINAL_EXTRA.get(cat, set())