bitrix-stages.ts
Language TypeScript11.8 KB225 lines
/*** Bitrix stage taxonomy: the most consequential file in the dashboard.** Bitrix has no concept of "open work." It has stages, and every record sits in* one. Whether a stage represents work Meridian owes, work we are waiting on someone* else for, or a record that has effectively finished is a judgment, and it* differs per stage. Bitrix cannot tell you.** Get it wrong and the dashboard reports 3,600 open records when the real number* of things anyone can act on is under 200, and a reader learns nothing except* that the dashboard is noise.** Classifications:** 'ours' Meridian owes the next move. The only class that counts as load.* 'waiting' Blocked on the client. Belongs on a follow-up list, not a workload* count. Aging here measures client responsiveness, not our capacity.* 'vendor' Blocked on a vendor or in transit. Track for delivery risk, not load.* 'parked' Deliberately paused. Excluded from everything; surface only on request.* 'terminal' Done, cancelled, or a graveyard stage nothing exits. Never counted.** Counts below are snapshot values from when the table was written, recorded so* the classifications can be sanity-checked against reality rather than argued* about in the abstract. They will drift; they are documentation, not logic.** The classifications are judgements from stage names. The 'waiting' vs* 'ours' calls are the ones to confirm with the people who run the pipeline,* because they decide whether a number represents the team's backlog or the* client's.*/export type StageClass = 'ours' | 'waiting' | 'vendor' | 'parked' | 'terminal'export interface StageRule {id: stringname: stringclass: StageClass/** Live count when classified, for sanity-checking the classification. */seen: number/** Why this classification, where it is not obvious from the name. */note?: string}export interface FunnelRules {categoryId: numbername: stringstages: StageRule[]}// --- Leads ------------------------------------------------------------------export const LEAD_STAGES: StageRule[] = [{ id: 'UC_VOHNVF', name: 'New/Unassigned', class: 'ours', seen: 4, note: 'Polluted: automated email captures land here.' },{ id: '7', name: 'Reorder in Progress', class: 'ours', seen: 17 },{ id: 'NEW', name: 'Allocated', class: 'ours', seen: 2 },{ id: 'PROCESSED', name: 'First Reply', class: 'ours', seen: 3 },{ id: 'UC_BB3F0W', name: 'Consultation Scheduled', class: 'waiting', seen: 2, note: 'Calendared; no action until the call.' },{ id: 'UC_E1JC2S', name: 'Quote Sent', class: 'waiting', seen: 11 },{ id: '9', name: 'Waiting For Client', class: 'waiting', seen: 7 },{ id: '2', name: 'No Reply', class: 'waiting', seen: 26, note: 'Largest single bucket. Aging here is the win-back signal.' },{ id: 'UC_Y4AE5V', name: 'Client Replied', class: 'ours', seen: 2, note: 'Client is waiting on US. Should surface first, always.' },{ id: 'UC_MUHHX6', name: 'Potential Partner', class: 'ours', seen: 1 },{ id: '6', name: 'Need Follow Up', class: 'ours', seen: 0 },{ id: 'UC_7WIO1E', name: 'Follow Up Sent', class: 'waiting', seen: 1 },{ id: 'IN_PROCESS', name: 'Quote Approved', class: 'ours', seen: 3, note: 'Approved and not yet converted. Revenue sitting still.' },{ id: '8', name: 'Parked', class: 'parked', seen: 6 },{ id: '10', name: 'Partially Converted', class: 'ours', seen: 0 },// --- terminal stages, confirmed in Bitrix ----------------------------------//// These five hold ~12,000 leads between them and none of them appear in// `crm.status` / pipeline_summary, which lists only the 15 process stages// above. They are real, named, and every one is an end state. Left// unclassified, they would make up most of the "real" leads in a 60-day// window as finished work counted as ours.//// Note especially "Converted Lead": a converted lead is a won lead. It became// an order and the order pipeline owns it from there. Counting it as an open// lead double-counts the same work in two systems.{ id: '3', name: 'Junk', class: 'terminal', seen: 7214, note: 'Call logs and marketing email. The single largest stage.' },{ id: '1', name: 'Reorder Completed', class: 'terminal', seen: 2406, note: 'Done. The order pipeline owns it from here.' },{ id: 'UC_GALOR0', name: 'Converted Lead', class: 'terminal', seen: 871, note: 'Became an order. Counting it here double-counts the work.' },{ id: 'UC_MIXSDC', name: 'Out of Scope', class: 'terminal', seen: 392, note: 'Handled, no sale. Product we do not carry, a service we do not offer, etc.' },{ id: 'JUNK', name: 'Unconverted Lead', class: 'terminal', seen: 1149, note: 'Bitrix semantic: failure.' },]// --- Deal funnels -----------------------------------------------------------export const PRE_PRODUCTION: FunnelRules = {categoryId: 18,name: 'Pre-Production',stages: [{ id: 'C18:NEW', name: 'Received', class: 'ours', seen: 41, note: 'Intake backlog. Largest actionable queue.' },{ id: 'C18:UC_A83HQM', name: 'Payment Error', class: 'ours', seen: 4, note: 'Exception. Revenue at risk; should be its own alert.' },{ id: 'C18:PREPARATION', name: 'Allocated', class: 'ours', seen: 3 },{ id: 'C18:UC_7GR1XY', name: 'Rush Prep', class: 'ours', seen: 1 },{ id: 'C18:UC_D13OS8', name: 'PO In Review', class: 'ours', seen: 0 },{ id: 'C18:UC_2GFE2Z', name: 'Payment Due', class: 'waiting', seen: 2 },{ id: 'C18:PREPAYMENT_INVOIC', name: 'Design Requested', class: 'ours', seen: 7, note: 'Design work. Note the stage id is a Bitrix default, unrelated to its label.' },{ id: 'C18:EXECUTING', name: 'Waiting On Files', class: 'waiting', seen: 1 },{ id: 'C18:FINAL_INVOICE', name: 'File Check', class: 'ours', seen: 38, note: 'THE designer queue. Closest thing to "how many mockups are open".' },{ id: 'C18:UC_9YHD3M', name: 'Proof Out', class: 'waiting', seen: 22, note: 'Client approval. NOT team load.' },{ id: 'C18:UC_RP5DP4', name: 'Proof Signed Off', class: 'ours', seen: 9, note: 'Approved and not yet moved to production. Should be near zero.' },{ id: 'C18:UC_MFAIIF', name: 'Parked', class: 'parked', seen: 0 },{ id: 'C18:UC_QV52SG', name: 'Cancel Requested', class: 'ours', seen: 6, note: 'Needs a human decision.' },{ id: 'C18:WON', name: 'Move to Production', class: 'terminal', seen: 0, note: 'Transition stage; records pass through to Production.' },{ id: 'C18:LOSE', name: 'Cancelled', class: 'terminal', seen: 84 },],}export const PRODUCTION: FunnelRules = {categoryId: 20,name: 'Production',stages: [{ id: 'C20:NEW', name: 'Cleared For Print', class: 'ours', seen: 10 },{ id: 'C20:PREPARATION', name: 'Rush Print', class: 'ours', seen: 1 },{ id: 'C20:PREPAYMENT_INVOIC', name: 'On Press', class: 'vendor', seen: 47, note: 'At the vendor. Track for delivery risk, not team capacity.' },{ id: 'C20:EXECUTING', name: 'Part Dispatched', class: 'ours', seen: 4, note: 'Split shipment needs chasing.' },{ id: 'C20:FINAL_INVOICE', name: 'Dispatched', class: 'vendor', seen: 39, note: 'In transit.' },{ id: 'C20:UC_RKULM8', name: 'PO Dispatched', class: 'vendor', seen: 0 },{ id: 'C20:UC_ZCL2EF', name: 'PO Paid', class: 'ours', seen: 0 },{ id: 'C20:WON', name: 'Move to Delivered', class: 'terminal', seen: 0 },{ id: 'C20:LOSE', name: 'Move to Cancel Requested', class: 'terminal', seen: 0 },{ id: 'C20:APOLOGY', name: 'Move to Park Order', class: 'parked', seen: 0 },],}export const POST_PRODUCTION: FunnelRules = {categoryId: 22,name: 'Post Production',stages: [{id: 'C22:NEW',name: 'Delivered',class: 'terminal',seen: 3612,note:'GRAVEYARD. Thousands of records here and none has ever reached Order Complete. ' +'Nothing exits this stage, so despite the name it is terminal in practice. ' +'Counting it as open would swamp every other number on the page by 20x.',},{ id: 'C22:UC_R3IKZ7', name: 'PO Payment Due', class: 'waiting', seen: 3 },{ id: 'C22:PREPARATION', name: 'Claim Opened', class: 'ours', seen: 0 },{ id: 'C22:WON', name: 'Order Complete', class: 'terminal', seen: 0 },{ id: 'C22:LOSE', name: 'Claim Settled', class: 'terminal', seen: 0 },],}export const FUNNELS = [PRE_PRODUCTION, PRODUCTION, POST_PRODUCTION]// --- helpers ----------------------------------------------------------------const byId = new Map<string, StageRule>()for (const s of LEAD_STAGES) byId.set(s.id, s)for (const f of FUNNELS) for (const s of f.stages) byId.set(s.id, s)/*** Bitrix's own grouping for a stage: 'P' in-process, 'S' success, 'F' failure.* Read-only, set by Bitrix, present on every lead as STATUS_SEMANTIC_ID.*/export type StageSemantic = 'P' | 'S' | 'F'/*** Classify a lead stage.** `semantic` matters more than the table above (rule 3). A hand-maintained* list of stages always lags the CRM, and a stage missing from it once* defaulted to 'ours' would put finished work into the headline as work the* team owes. Bitrix already says which stages are finished, in* STATUS_SEMANTIC_ID: 'S' and 'F' are both end states regardless of what* anybody named the stage.** So the fallback for an unknown stage is the semantic, not 'ours'. A stage* someone adds tomorrow classifies correctly with no config change, and only a* stage that is genuinely still in process and unknown falls through to* 'ours', where it is loud rather than wrong (rule 2).** The stage is authoritative: do not read the lead title to override it.* Something writes rich AI summaries into Bitrix lead titles, carrying tags like* `[CONVERT]`, `[keep]`, `[callback]` and `[urgent · PDF owed]`. The* temptation is to parse them and reclassify: one such title says a promised PDF* never went out, on a lead correctly staged `Converted Lead`.** Do not. Stages are moved by hand and reviewed by the people who maintain the* pipeline, so the stage is the reviewed human judgement about where a lead stands. A tag is an* unreviewed machine annotation. Letting the second override the first would* replace the most trustworthy signal in this dataset with the least, and would* silently disagree with what the two people who maintain the pipeline see when* they open it.** The accepted cost, stated rather than hidden: a commitment that lives only in a* title is invisible to this board. The owed PDF above is real, is not captured* by any field, and will not appear here. That is a gap in Bitrix's data model,* not something classification should paper over, and inferring it would mean* this board contradicting the CRM on the CRM's own subject.*/export function classify(stageId: string, semantic?: StageSemantic | null): StageClass {const known = byId.get(stageId)if (known) return known.classif (semantic === 'S' || semantic === 'F') return 'terminal'// Genuinely unknown and genuinely in-process. Count it (silently dropping// unknown work is worse than over-counting it), but the caller must surface// `unknownStageIds()` next to the number. See headline().unknownLeadStages.return 'ours'}/*** Lead stage ids present in the data but absent from this table, with counts.** The table was written from a sample, and the real pipeline can sit in stages* nobody has classified. Until they are, the headline is counting unclassified* rows as team workload, and the caller must say so.*/export function unknownStageIds(stageIds: string[]): { id: string; count: number }[] {const counts = new Map<string, number>()for (const id of stageIds) {if (byId.has(id)) continuecounts.set(id, (counts.get(id) ?? 0) + 1)}return [...counts].map(([id, count]) => ({ id, count })).sort((a, b) => b.count - a.count)}export function stageName(stageId: string): string {return byId.get(stageId)?.name ?? stageId}