mattrguerra/meridian-ops-boardmainPrivate on GitHub, published here in full

bitrix-stages.ts

Language TypeScript11.8 KB225 lines

config/bitrix-stages.ts

  1. /**
  2. * Bitrix stage taxonomy: the most consequential file in the dashboard.
  3. *
  4. * Bitrix has no concept of "open work." It has stages, and every record sits in
  5. * one. Whether a stage represents work Meridian owes, work we are waiting on someone
  6. * else for, or a record that has effectively finished is a judgment, and it
  7. * differs per stage. Bitrix cannot tell you.
  8. *
  9. * Get it wrong and the dashboard reports 3,600 open records when the real number
  10. * of things anyone can act on is under 200, and a reader learns nothing except
  11. * that the dashboard is noise.
  12. *
  13. * Classifications:
  14. *
  15. * 'ours' Meridian owes the next move. The only class that counts as load.
  16. * 'waiting' Blocked on the client. Belongs on a follow-up list, not a workload
  17. * count. Aging here measures client responsiveness, not our capacity.
  18. * 'vendor' Blocked on a vendor or in transit. Track for delivery risk, not load.
  19. * 'parked' Deliberately paused. Excluded from everything; surface only on request.
  20. * 'terminal' Done, cancelled, or a graveyard stage nothing exits. Never counted.
  21. *
  22. * Counts below are snapshot values from when the table was written, recorded so
  23. * the classifications can be sanity-checked against reality rather than argued
  24. * about in the abstract. They will drift; they are documentation, not logic.
  25. *
  26. * The classifications are judgements from stage names. The 'waiting' vs
  27. * 'ours' calls are the ones to confirm with the people who run the pipeline,
  28. * because they decide whether a number represents the team's backlog or the
  29. * client's.
  30. */
  31. export type StageClass = 'ours' | 'waiting' | 'vendor' | 'parked' | 'terminal'
  32. export interface StageRule {
  33. id: string
  34. name: string
  35. class: StageClass
  36. /** Live count when classified, for sanity-checking the classification. */
  37. seen: number
  38. /** Why this classification, where it is not obvious from the name. */
  39. note?: string
  40. }
  41. export interface FunnelRules {
  42. categoryId: number
  43. name: string
  44. stages: StageRule[]
  45. }
  46. // --- Leads ------------------------------------------------------------------
  47. export const LEAD_STAGES: StageRule[] = [
  48. { id: 'UC_VOHNVF', name: 'New/Unassigned', class: 'ours', seen: 4, note: 'Polluted: automated email captures land here.' },
  49. { id: '7', name: 'Reorder in Progress', class: 'ours', seen: 17 },
  50. { id: 'NEW', name: 'Allocated', class: 'ours', seen: 2 },
  51. { id: 'PROCESSED', name: 'First Reply', class: 'ours', seen: 3 },
  52. { id: 'UC_BB3F0W', name: 'Consultation Scheduled', class: 'waiting', seen: 2, note: 'Calendared; no action until the call.' },
  53. { id: 'UC_E1JC2S', name: 'Quote Sent', class: 'waiting', seen: 11 },
  54. { id: '9', name: 'Waiting For Client', class: 'waiting', seen: 7 },
  55. { id: '2', name: 'No Reply', class: 'waiting', seen: 26, note: 'Largest single bucket. Aging here is the win-back signal.' },
  56. { id: 'UC_Y4AE5V', name: 'Client Replied', class: 'ours', seen: 2, note: 'Client is waiting on US. Should surface first, always.' },
  57. { id: 'UC_MUHHX6', name: 'Potential Partner', class: 'ours', seen: 1 },
  58. { id: '6', name: 'Need Follow Up', class: 'ours', seen: 0 },
  59. { id: 'UC_7WIO1E', name: 'Follow Up Sent', class: 'waiting', seen: 1 },
  60. { id: 'IN_PROCESS', name: 'Quote Approved', class: 'ours', seen: 3, note: 'Approved and not yet converted. Revenue sitting still.' },
  61. { id: '8', name: 'Parked', class: 'parked', seen: 6 },
  62. { id: '10', name: 'Partially Converted', class: 'ours', seen: 0 },
  63. // --- terminal stages, confirmed in Bitrix ----------------------------------
  64. //
  65. // These five hold ~12,000 leads between them and none of them appear in
  66. // `crm.status` / pipeline_summary, which lists only the 15 process stages
  67. // above. They are real, named, and every one is an end state. Left
  68. // unclassified, they would make up most of the "real" leads in a 60-day
  69. // window as finished work counted as ours.
  70. //
  71. // Note especially "Converted Lead": a converted lead is a won lead. It became
  72. // an order and the order pipeline owns it from there. Counting it as an open
  73. // lead double-counts the same work in two systems.
  74. { id: '3', name: 'Junk', class: 'terminal', seen: 7214, note: 'Call logs and marketing email. The single largest stage.' },
  75. { id: '1', name: 'Reorder Completed', class: 'terminal', seen: 2406, note: 'Done. The order pipeline owns it from here.' },
  76. { id: 'UC_GALOR0', name: 'Converted Lead', class: 'terminal', seen: 871, note: 'Became an order. Counting it here double-counts the work.' },
  77. { 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.' },
  78. { id: 'JUNK', name: 'Unconverted Lead', class: 'terminal', seen: 1149, note: 'Bitrix semantic: failure.' },
  79. ]
  80. // --- Deal funnels -----------------------------------------------------------
  81. export const PRE_PRODUCTION: FunnelRules = {
  82. categoryId: 18,
  83. name: 'Pre-Production',
  84. stages: [
  85. { id: 'C18:NEW', name: 'Received', class: 'ours', seen: 41, note: 'Intake backlog. Largest actionable queue.' },
  86. { id: 'C18:UC_A83HQM', name: 'Payment Error', class: 'ours', seen: 4, note: 'Exception. Revenue at risk; should be its own alert.' },
  87. { id: 'C18:PREPARATION', name: 'Allocated', class: 'ours', seen: 3 },
  88. { id: 'C18:UC_7GR1XY', name: 'Rush Prep', class: 'ours', seen: 1 },
  89. { id: 'C18:UC_D13OS8', name: 'PO In Review', class: 'ours', seen: 0 },
  90. { id: 'C18:UC_2GFE2Z', name: 'Payment Due', class: 'waiting', seen: 2 },
  91. { 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.' },
  92. { id: 'C18:EXECUTING', name: 'Waiting On Files', class: 'waiting', seen: 1 },
  93. { id: 'C18:FINAL_INVOICE', name: 'File Check', class: 'ours', seen: 38, note: 'THE designer queue. Closest thing to "how many mockups are open".' },
  94. { id: 'C18:UC_9YHD3M', name: 'Proof Out', class: 'waiting', seen: 22, note: 'Client approval. NOT team load.' },
  95. { id: 'C18:UC_RP5DP4', name: 'Proof Signed Off', class: 'ours', seen: 9, note: 'Approved and not yet moved to production. Should be near zero.' },
  96. { id: 'C18:UC_MFAIIF', name: 'Parked', class: 'parked', seen: 0 },
  97. { id: 'C18:UC_QV52SG', name: 'Cancel Requested', class: 'ours', seen: 6, note: 'Needs a human decision.' },
  98. { id: 'C18:WON', name: 'Move to Production', class: 'terminal', seen: 0, note: 'Transition stage; records pass through to Production.' },
  99. { id: 'C18:LOSE', name: 'Cancelled', class: 'terminal', seen: 84 },
  100. ],
  101. }
  102. export const PRODUCTION: FunnelRules = {
  103. categoryId: 20,
  104. name: 'Production',
  105. stages: [
  106. { id: 'C20:NEW', name: 'Cleared For Print', class: 'ours', seen: 10 },
  107. { id: 'C20:PREPARATION', name: 'Rush Print', class: 'ours', seen: 1 },
  108. { id: 'C20:PREPAYMENT_INVOIC', name: 'On Press', class: 'vendor', seen: 47, note: 'At the vendor. Track for delivery risk, not team capacity.' },
  109. { id: 'C20:EXECUTING', name: 'Part Dispatched', class: 'ours', seen: 4, note: 'Split shipment needs chasing.' },
  110. { id: 'C20:FINAL_INVOICE', name: 'Dispatched', class: 'vendor', seen: 39, note: 'In transit.' },
  111. { id: 'C20:UC_RKULM8', name: 'PO Dispatched', class: 'vendor', seen: 0 },
  112. { id: 'C20:UC_ZCL2EF', name: 'PO Paid', class: 'ours', seen: 0 },
  113. { id: 'C20:WON', name: 'Move to Delivered', class: 'terminal', seen: 0 },
  114. { id: 'C20:LOSE', name: 'Move to Cancel Requested', class: 'terminal', seen: 0 },
  115. { id: 'C20:APOLOGY', name: 'Move to Park Order', class: 'parked', seen: 0 },
  116. ],
  117. }
  118. export const POST_PRODUCTION: FunnelRules = {
  119. categoryId: 22,
  120. name: 'Post Production',
  121. stages: [
  122. {
  123. id: 'C22:NEW',
  124. name: 'Delivered',
  125. class: 'terminal',
  126. seen: 3612,
  127. note:
  128. 'GRAVEYARD. Thousands of records here and none has ever reached Order Complete. ' +
  129. 'Nothing exits this stage, so despite the name it is terminal in practice. ' +
  130. 'Counting it as open would swamp every other number on the page by 20x.',
  131. },
  132. { id: 'C22:UC_R3IKZ7', name: 'PO Payment Due', class: 'waiting', seen: 3 },
  133. { id: 'C22:PREPARATION', name: 'Claim Opened', class: 'ours', seen: 0 },
  134. { id: 'C22:WON', name: 'Order Complete', class: 'terminal', seen: 0 },
  135. { id: 'C22:LOSE', name: 'Claim Settled', class: 'terminal', seen: 0 },
  136. ],
  137. }
  138. export const FUNNELS = [PRE_PRODUCTION, PRODUCTION, POST_PRODUCTION]
  139. // --- helpers ----------------------------------------------------------------
  140. const byId = new Map<string, StageRule>()
  141. for (const s of LEAD_STAGES) byId.set(s.id, s)
  142. for (const f of FUNNELS) for (const s of f.stages) byId.set(s.id, s)
  143. /**
  144. * Bitrix's own grouping for a stage: 'P' in-process, 'S' success, 'F' failure.
  145. * Read-only, set by Bitrix, present on every lead as STATUS_SEMANTIC_ID.
  146. */
  147. export type StageSemantic = 'P' | 'S' | 'F'
  148. /**
  149. * Classify a lead stage.
  150. *
  151. * `semantic` matters more than the table above (rule 3). A hand-maintained
  152. * list of stages always lags the CRM, and a stage missing from it once
  153. * defaulted to 'ours' would put finished work into the headline as work the
  154. * team owes. Bitrix already says which stages are finished, in
  155. * STATUS_SEMANTIC_ID: 'S' and 'F' are both end states regardless of what
  156. * anybody named the stage.
  157. *
  158. * So the fallback for an unknown stage is the semantic, not 'ours'. A stage
  159. * someone adds tomorrow classifies correctly with no config change, and only a
  160. * stage that is genuinely still in process and unknown falls through to
  161. * 'ours', where it is loud rather than wrong (rule 2).
  162. *
  163. * The stage is authoritative: do not read the lead title to override it.
  164. * Something writes rich AI summaries into Bitrix lead titles, carrying tags like
  165. * `[CONVERT]`, `[keep]`, `[callback]` and `[urgent · PDF owed]`. The
  166. * temptation is to parse them and reclassify: one such title says a promised PDF
  167. * never went out, on a lead correctly staged `Converted Lead`.
  168. *
  169. * Do not. Stages are moved by hand and reviewed by the people who maintain the
  170. * pipeline, so the stage is the reviewed human judgement about where a lead stands. A tag is an
  171. * unreviewed machine annotation. Letting the second override the first would
  172. * replace the most trustworthy signal in this dataset with the least, and would
  173. * silently disagree with what the two people who maintain the pipeline see when
  174. * they open it.
  175. *
  176. * The accepted cost, stated rather than hidden: a commitment that lives only in a
  177. * title is invisible to this board. The owed PDF above is real, is not captured
  178. * by any field, and will not appear here. That is a gap in Bitrix's data model,
  179. * not something classification should paper over, and inferring it would mean
  180. * this board contradicting the CRM on the CRM's own subject.
  181. */
  182. export function classify(stageId: string, semantic?: StageSemantic | null): StageClass {
  183. const known = byId.get(stageId)
  184. if (known) return known.class
  185. if (semantic === 'S' || semantic === 'F') return 'terminal'
  186. // Genuinely unknown and genuinely in-process. Count it (silently dropping
  187. // unknown work is worse than over-counting it), but the caller must surface
  188. // `unknownStageIds()` next to the number. See headline().unknownLeadStages.
  189. return 'ours'
  190. }
  191. /**
  192. * Lead stage ids present in the data but absent from this table, with counts.
  193. *
  194. * The table was written from a sample, and the real pipeline can sit in stages
  195. * nobody has classified. Until they are, the headline is counting unclassified
  196. * rows as team workload, and the caller must say so.
  197. */
  198. export function unknownStageIds(stageIds: string[]): { id: string; count: number }[] {
  199. const counts = new Map<string, number>()
  200. for (const id of stageIds) {
  201. if (byId.has(id)) continue
  202. counts.set(id, (counts.get(id) ?? 0) + 1)
  203. }
  204. return [...counts].map(([id, count]) => ({ id, count })).sort((a, b) => b.count - a.count)
  205. }
  206. export function stageName(stageId: string): string {
  207. return byId.get(stageId)?.name ?? stageId
  208. }