stages.py
Language Python7.8 KB157 lines
"""Bitrix funnels and stage tokens: names, sends, terminal stages."""from __future__ import annotations# Stage names are keyed by stage token, never by display name: display names get# renamed in Bitrix and the same label can mean different things in different funnels.# The three funnels the census maps (reconcile-deals, route).FUNNEL_KEYS = {"24": "partner_outreach", "26": "winback", "36": "quarterly_checkin"}FUNNEL_DISPLAY = {"24": "Partner Tier Upgrade", "26": "Winback","36": "Quarterly Check-In"}STAGE_NAMES = {"24": {"UC_2YNXWE": "LinkedIn Research & Connection", "NEW": "Update Info Plinth","UC_6H9C8U": "TIER CHANGE EMAIL SENT", "UC_5DTN59": "Need Artwork/Mockup","PREPAYMENT_INVOIC": "Gift Mockup Sent", "EXECUTING": "No Reply Mockup","UC_PT8D2U": "Client Approved", "FINAL_INVOICE": "Gift Sent","UC_HWT78S": "Gift Delivered","UC_L2S2TK": "Potential Partners (PP) - HAZEL ONLY","UC_2EYUH4": "PP LinkedIn Connection - HAZEL ONLY","UC_2DE6NT": "PP Email Reachout Sent - HAZEL ONLY","UC_RCNECL": "Client Replied", "UC_N5U7JC": "Qtly Touch-Point Scheduled","WON": "Deal won", "LOSE": "Not Interested"},"26": {"UC_JUUNES": "Client", "UC_L2JMHY": "Activated","NEW": "LinkedIn Research & Connection","PREPARATION": "First Email", "UC_K9M6LT": "No Reply 1st Email (Auto)","PREPAYMENT_INVOIC": "Follow-Up Email", "UC_D6LU8W": "No Reply 2nd Email (Auto)","EXECUTING": "LinkedIn DM", "FINAL_INVOICE": "Pre-Call Email","UC_BMQAKY": "Call Booked / Voicemail", "UC_EPG639": "Changed Companies","UC_YUE3NB": "Placed Order / Quote", "WON": "Winback Outreach Completed","LOSE": "No Reply from Customer"},"36": {"NEW": "Active Check-In List", "UC_LXCMAV": "Dormant","PREPARATION": "LinkedIn Research & Connection","PREPAYMENT_INVOIC": "First Email Sent", "UC_X25FL3": "Call Booked","UC_7XQR9H": "No - Reply", "EXECUTING": "Second Email Sent","UC_32CNNC": "Email Defunct", "FINAL_INVOICE": "Changed Companies/Jobs","WON": "Quarterly Completed", "LOSE": "Never Replied", "1": "Not Interested"},}# Check a stage rename against live pipeline output before editing this table: a name# recorded against the wrong token is written onto every census block in that stage# at the next reconcile-deals run (C36:NEW is "Active Check-In List", C36:UC_LXCMAV is# "Dormant"). A token missing here resolves to the raw token, with a warning.# "Not Interested"-class closures are opt-outs: full email suppression, see DECISIONS.md.OPTOUT_STAGES = {"Not Interested"}# The outreach audit (Stage F5) covers the funnels that carry client outreach: the three# the census maps plus Abandoned Carts. Order, production and fulfillment funnels are# left out because customer email there is order handling, not outreach, and so are# funnels that send email for other reasons (delivery follow-up, cold outreach): this# is a client-outreach tracker, not an inventory of every funnel that sends email.# The scope is a recorded decision (DECISIONS.md); change it there first.AUDIT_FUNNELS = {"24": "Partner Tier Upgrade","26": "Winback","36": "Quarterly Check-In Flow","16": "Abandoned Carts",}AUDIT_TAB = {"24": "Partner Tier Upgrade (24)", "26": "Winback (26)","36": "Quarterly Check-In (36)", "16": "Abandoned Carts (16)",}# Stage names for the audit-only funnel (16). Kept separate from STAGE_NAMES so# reconcile-deals' cat 24/26/36 behaviour is untouched.AUDIT_STAGE_NAMES = {"16": {"NEW": "New Abandoned Cart", "UC_2AAJ7Q": "Guests","PREPARATION": "First Email Sent", "UC_6V2M93": "No Reply 1st Email","PREPAYMENT_INVOIC": "Follow-Up Email Sent", "EXECUTING": "No Reply 2nd Email","WON": "Converted", "LOSE": "Not Converted / Closed"},}# Retired stages were deleted from the Bitrix funnel config, so the API returns a bare# token with no name and a naive audit silently drops them. They were identified by# matching each stage-entry timestamp against the deal's outbound email timeline (see# DECISIONS.md). Do not remove these: stage histories still reference them, and without# them clients who were emailed read as never contacted.RETIRED_STAGE_NAMES = {"24": {"PREPARATION": "Email Sent (retired)","UC_CEAGAV": "2nd Reachout - call + email (retired)","UC_V6JARB": "3rd Reachout - follow-up email (retired)","UC_N4JCMW": "Bulk re-stage, no send (retired)"},"26": {"UC_PMLDJW": "Feedback Survey Email (retired)","UC_GDHDJR": "Feedback Survey Reminder Email (retired)"},}for _c, _m in RETIRED_STAGE_NAMES.items():STAGE_NAMES.setdefault(_c, {})for _t, _n in _m.items():STAGE_NAMES[_c].setdefault(_t, _n) # setdefault: never shadow a live stage# Stage tokens where a real outbound email, call or LinkedIn DM sits behind the stage# move. Everything else is a system reaction (No - Reply, No Reply Nth Email (Auto),# Email Defunct, Changed Companies, Not Interested, Completed) and must not count as# contact.## Two traps, both checked against deal timelines:# * cat 26 FINAL_INVOICE is called "Pre-Call Email" and is a real send. A funnel# outside this audit's scope uses that same label for its creation stage, which# sends nothing, so the rule is keyed on the token, never on the stage name.# * cat 26 UC_L2JMHY ("Activated") is excluded on purpose. It is the intake bucket# clients land in when they are made active in Winback: the funnel is too big to# work in one quarter, so activating is how a slice gets picked up for this quarter.# Nothing is sent on that move; the send happens further down the funnel. Adding it# here would read every activation as outreach.SEND_TOKENS = {"24": {"UC_6H9C8U", "PREPAYMENT_INVOIC", "FINAL_INVOICE", "UC_2DE6NT","PREPARATION", "UC_CEAGAV", "UC_V6JARB"}, # not UC_N4JCMW: a bulk re-stage, no send"26": {"PREPARATION", "PREPAYMENT_INVOIC", "EXECUTING", "FINAL_INVOICE","UC_BMQAKY", "UC_PMLDJW", "UC_GDHDJR"},"36": {"PREPAYMENT_INVOIC", "EXECUTING"},"16": {"PREPARATION", "PREPAYMENT_INVOIC"},}# Terminal stages: once a deal is here and its stage has not changed since the last# run, its touch history is treated as frozen and reused from the cache instead of# being re-pulled. This is what makes the audit affordable weekly.TERMINAL_TOKENS = {"WON", "LOSE"}TERMINAL_EXTRA = {"36": {"1"}} # cat 36 "Not Interested" is its own token# Known test / non-client records. Flagged in the sheet, never deleted.TEST_DEALS = {"400017", "400023"}def token(stage_id: str) -> str:"""The stage token of a Bitrix stage id: "C36:EXECUTING" -> "EXECUTING"."""return stage_id.split(":", 1)[1] if ":" in stage_id else stage_iddef stage_status(cat: str, stage_id: str) -> tuple[str, str]:"""(human stage name, block status) from a raw Bitrix stage id like C36:EXECUTING."""tok = token(stage_id)name = STAGE_NAMES.get(cat, {}).get(tok)if name is None:print(f"WARNING: unknown stage {stage_id} in cat {cat}: using raw token. ""Add it to STAGE_NAMES.")name = tokif tok == "WON":status = "completed"elif tok == "LOSE" or (cat == "36" and tok == "1"):status = "lost"else:status = "active"return name, statusdef audit_stage_name(cat: str, stage_id: str) -> str:tok = token(stage_id)for src in (STAGE_NAMES, AUDIT_STAGE_NAMES, RETIRED_STAGE_NAMES):n = src.get(cat, {}).get(tok)if n:return nreturn tokdef is_terminal(cat: str, stage_id: str) -> bool:tok = token(stage_id)return tok in TERMINAL_TOKENS or tok in TERMINAL_EXTRA.get(cat, set())