v1.2.0

CRM analytics — sales funnel

Stream all deals via actions.v2.fetchList.make, group by stage, print a funnel report (counts, conversion %, avg ticket, win rate).

What it does

Loads every deal on the portal in chunks (no loading all rows into RAM), groups them by stage, and prints a console funnel report: counts, conversion percentages, average ticket, total revenue, win rate.

Stack

Node.js 18+. No external dependencies.

Environment

export B24_HOOK='https://your.bitrix24.com/rest/1/secret'

Run

npx tsx 01-crm-analytics.ts

Code

01-crm-analytics.ts
import {
  B24Hook,
  ConsoleV2Handler,
  EnumCrmEntityTypeId,
  LogLevel,
  Logger,
  type TypeB24
} from '@bitrix24/b24jssdk'

// New SDK Logger. See /docs/working-with-the-rest-api/logger/.
const logger = Logger.create('CrmAnalytics')
logger.pushHandler(new ConsoleV2Handler(LogLevel.INFO, { useStyles: false }))

function bootB24(): TypeB24 {
  const url = process.env.B24_HOOK
  if (!url) throw new Error('B24_HOOK env var is required')
  const $b24 = B24Hook.fromWebhookUrl(url)
  $b24.offClientSideWarning?.()
  return $b24
}

const STAGE_NAMES: Record<string, string> = {
  NEW: 'New',
  PREPARATION: 'Preparation',
  PREPAYMENT_INVOICE: 'Prepayment invoice',
  EXECUTING: 'Executing',
  FINAL_INVOICE: 'Final invoice',
  WON: 'Won',
  LOSE: 'Lost'
}

const baseStage = (s: string) => (s.includes(':') ? s.split(':')[1] : s)

interface DealRow {
  id: number
  stageId: string
  opportunity: number
  currencyId: string
}

async function loadAllDeals($b24: TypeB24): Promise<DealRow[]> {
  const out: DealRow[] = []
  // Heartbeat every N items so a long load on a large portal doesn't look
  // like a hang. Drop on small portals where the runtime is sub-second.
  const progressEvery = 500
  let nextProgressAt = progressEvery
  const startedAt = Date.now()

  const generator = $b24.actions.v2.fetchList.make<DealRow>({
    method: 'crm.item.list',
    params: {
      entityTypeId: EnumCrmEntityTypeId.deal,
      select: ['id', 'stageId', 'opportunity', 'currencyId']
    },
    idKey: 'id',
    customKeyForResult: 'items',
    requestId: 'load-deals'
  })

  for await (const chunk of generator) {
    for (const it of chunk) {
      out.push({
        id: Number(it.id),
        stageId: it.stageId,
        opportunity: Number(it.opportunity ?? 0),
        currencyId: it.currencyId ?? 'RUB'
      })
    }
    if (out.length >= nextProgressAt) {
      const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1)
      logger.info(`${out.length} deals so far (${elapsed}s)`)
      nextProgressAt += progressEvery
    }
  }
  return out
}

interface StageStat { count: number; total: number }

function analyseFunnel(deals: DealRow[]): Map<string, StageStat> {
  const stages = new Map<string, StageStat>()
  for (const d of deals) {
    const s = stages.get(d.stageId) ?? { count: 0, total: 0 }
    s.count += 1
    s.total += d.opportunity
    stages.set(d.stageId, s)
  }
  return stages
}

function printFunnel(stages: Map<string, StageStat>, totalDeals: number) {
  const order = ['NEW', 'PREPARATION', 'PREPAYMENT_INVOICE', 'EXECUTING', 'FINAL_INVOICE', 'WON', 'LOSE']
  console.log(`Total deals: ${totalDeals}`)
  for (const baseId of order) {
    let count = 0
    let total = 0
    for (const [sid, s] of stages) {
      if (baseStage(sid) === baseId) { count += s.count; total += s.total }
    }
    if (count === 0) continue
    const conversion = totalDeals > 0 ? (count / totalDeals) * 100 : 0
    const avg = count > 0 ? total / count : 0
    console.log(`  ${STAGE_NAMES[baseId].padEnd(25)} ${String(count).padStart(8)} ${conversion.toFixed(1).padStart(9)}%  avg=${avg.toFixed(0)}`)
  }
}

async function main() {
  const $b24 = bootB24()
  const deals = await loadAllDeals($b24)
  logger.info(`Loaded ${deals.length} deals`)
  printFunnel(analyseFunnel(deals), deals.length)
}

main().catch((e: unknown) => {
  console.error('\n[recipe failed]', e instanceof Error ? `${e.name}: ${e.message}` : String(e))
  if (e instanceof Error && e.stack) console.error(e.stack)
  process.exit(1)
})

Notes

  • Multi-funnel portals: stage IDs come prefixed (C2:WON). The baseStage() helper aggregates them under the base name.
  • fetchList.make keeps memory bounded — it streams 50-row chunks internally using a keyset cursor on idKey.
  • The action strips any user-supplied order because keyset pagination requires idKey ASC — narrow with filter instead.
  • For a more efficient revenue roll-up an actions.v3.aggregate.make would issue a single sum call, but that action surface is not yet exposed in the SDK.