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). ThebaseStage()helper aggregates them under the base name. fetchList.makekeeps memory bounded — it streams 50-row chunks internally using a keyset cursor onidKey.- The action strips any user-supplied
orderbecause keyset pagination requiresidKey ASC— narrow withfilterinstead. - For a more efficient revenue roll-up an
actions.v3.aggregate.makewould issue a singlesumcall, but that action surface is not yet exposed in the SDK.