@wavyx/pdcli 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/README.md +11 -0
- package/bin/dev.js +19 -0
- package/oclif.manifest.json +503 -35
- package/package.json +1 -1
- package/src/commands/audit.js +137 -0
- package/src/commands/funnel.js +92 -0
- package/src/commands/metrics/velocity.js +81 -0
- package/src/commands/pipeline/health.js +78 -0
- package/src/lib/analytics.js +154 -0
- package/src/lib/audit.js +228 -0
- package/src/lib/period.js +33 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { Flags } from '@oclif/core'
|
|
2
|
+
import chalk from 'chalk'
|
|
3
|
+
import ora from 'ora'
|
|
4
|
+
import BaseCommand from '../base-command.js'
|
|
5
|
+
import { collectPages } from '../lib/pagination.js'
|
|
6
|
+
import { runChecks, AUDIT_CHECKS } from '../lib/audit.js'
|
|
7
|
+
import { CliError } from '../lib/errors.js'
|
|
8
|
+
|
|
9
|
+
export default class AuditCommand extends BaseCommand {
|
|
10
|
+
static description =
|
|
11
|
+
'Data-quality audit: stale deals, missing fields, duplicates, overdue pileups'
|
|
12
|
+
|
|
13
|
+
static examples = [
|
|
14
|
+
'<%= config.bin %> audit',
|
|
15
|
+
'<%= config.bin %> audit --checks stale-deals,duplicate-persons --verbose',
|
|
16
|
+
'<%= config.bin %> audit --strict # exit 1 on must-severity findings (CI)',
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
static flags = {
|
|
20
|
+
...BaseCommand.baseFlags,
|
|
21
|
+
checks: Flags.string({
|
|
22
|
+
description: `Comma-separated subset of checks (${AUDIT_CHECKS.map((c) => c.name).join(', ')})`,
|
|
23
|
+
}),
|
|
24
|
+
strict: Flags.boolean({
|
|
25
|
+
description: 'Exit 1 when any must-severity check has findings',
|
|
26
|
+
default: false,
|
|
27
|
+
}),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async run() {
|
|
31
|
+
const { flags } = await this.parse(AuditCommand)
|
|
32
|
+
const now = new Date()
|
|
33
|
+
|
|
34
|
+
const only = flags.checks?.split(',').map((c) => c.trim())
|
|
35
|
+
if (only) {
|
|
36
|
+
const known = new Set(AUDIT_CHECKS.map((c) => c.name))
|
|
37
|
+
const bad = only.filter((c) => !known.has(c))
|
|
38
|
+
if (bad.length > 0) {
|
|
39
|
+
throw new CliError(
|
|
40
|
+
`Unknown check${bad.length > 1 ? 's' : ''}: ${bad.join(', ')}. ` +
|
|
41
|
+
`Valid: ${[...known].join(', ')}`,
|
|
42
|
+
{ exitCode: 64 },
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const spinner = ora('Fetching account data...').start()
|
|
48
|
+
let data
|
|
49
|
+
try {
|
|
50
|
+
// v2 has no all_not_deleted status — fetch the three states separately.
|
|
51
|
+
const [open, won, lost, persons, organizations, activities] =
|
|
52
|
+
await Promise.all([
|
|
53
|
+
collectPages(
|
|
54
|
+
this.apiClient.pageV2('/api/v2/deals', {
|
|
55
|
+
status: 'open',
|
|
56
|
+
limit: 500,
|
|
57
|
+
}),
|
|
58
|
+
),
|
|
59
|
+
collectPages(
|
|
60
|
+
this.apiClient.pageV2('/api/v2/deals', {
|
|
61
|
+
status: 'won',
|
|
62
|
+
limit: 500,
|
|
63
|
+
}),
|
|
64
|
+
),
|
|
65
|
+
collectPages(
|
|
66
|
+
this.apiClient.pageV2('/api/v2/deals', {
|
|
67
|
+
status: 'lost',
|
|
68
|
+
limit: 500,
|
|
69
|
+
}),
|
|
70
|
+
),
|
|
71
|
+
collectPages(
|
|
72
|
+
this.apiClient.pageV2('/api/v2/persons', { limit: 500 }),
|
|
73
|
+
),
|
|
74
|
+
collectPages(
|
|
75
|
+
this.apiClient.pageV2('/api/v2/organizations', { limit: 500 }),
|
|
76
|
+
),
|
|
77
|
+
collectPages(
|
|
78
|
+
this.apiClient.pageV2('/api/v2/activities', { limit: 500 }),
|
|
79
|
+
),
|
|
80
|
+
])
|
|
81
|
+
data = {
|
|
82
|
+
deals: [...open, ...won, ...lost],
|
|
83
|
+
persons,
|
|
84
|
+
organizations,
|
|
85
|
+
activities,
|
|
86
|
+
}
|
|
87
|
+
} finally {
|
|
88
|
+
spinner.stop()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const results = runChecks(data, { now, only })
|
|
92
|
+
|
|
93
|
+
if (this.resolveFormat() === 'table') {
|
|
94
|
+
await this.outputResults(results, {
|
|
95
|
+
severity: {
|
|
96
|
+
header: 'Sev',
|
|
97
|
+
get: (row) => (row.severity === 'must' ? '●' : '○'),
|
|
98
|
+
},
|
|
99
|
+
title: { header: 'Check' },
|
|
100
|
+
count: {
|
|
101
|
+
header: 'Findings',
|
|
102
|
+
get: (row) =>
|
|
103
|
+
row.count === 0
|
|
104
|
+
? chalk.green('0')
|
|
105
|
+
: chalk.yellow(String(row.count)),
|
|
106
|
+
},
|
|
107
|
+
})
|
|
108
|
+
if (this.flags.verbose) {
|
|
109
|
+
for (const result of results.filter((r) => r.count > 0)) {
|
|
110
|
+
this.log('')
|
|
111
|
+
this.log(chalk.bold(result.title))
|
|
112
|
+
for (const item of result.items.slice(0, 25)) {
|
|
113
|
+
this.log(` ${JSON.stringify(item)}`)
|
|
114
|
+
}
|
|
115
|
+
if (result.items.length > 25) {
|
|
116
|
+
this.log(chalk.dim(` … ${result.items.length - 25} more`))
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
await this.outputResults(results, {})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (flags.strict) {
|
|
125
|
+
const mustHits = results.filter(
|
|
126
|
+
(r) => r.severity === 'must' && r.count > 0,
|
|
127
|
+
)
|
|
128
|
+
if (mustHits.length > 0) {
|
|
129
|
+
throw new CliError(
|
|
130
|
+
`${mustHits.length} must-severity check${mustHits.length > 1 ? 's' : ''} ` +
|
|
131
|
+
`found issues: ${mustHits.map((r) => r.name).join(', ')}`,
|
|
132
|
+
{ exitCode: 1 },
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { Flags } from '@oclif/core'
|
|
2
|
+
import BaseCommand from '../base-command.js'
|
|
3
|
+
import { collectPages } from '../lib/pagination.js'
|
|
4
|
+
import { parsePeriod, formatApiDatetime } from '../lib/period.js'
|
|
5
|
+
import { computeFunnel } from '../lib/analytics.js'
|
|
6
|
+
import { CliError } from '../lib/errors.js'
|
|
7
|
+
|
|
8
|
+
export default class FunnelCommand extends BaseCommand {
|
|
9
|
+
static description =
|
|
10
|
+
'Stage-to-stage conversion approximated from closed deals (final stage reached)'
|
|
11
|
+
|
|
12
|
+
static examples = [
|
|
13
|
+
'<%= config.bin %> funnel',
|
|
14
|
+
'<%= config.bin %> funnel --pipeline 1 --period 180d',
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
static flags = {
|
|
18
|
+
...BaseCommand.baseFlags,
|
|
19
|
+
period: Flags.string({
|
|
20
|
+
description: 'Trailing window for closed deals (Nd or Nm)',
|
|
21
|
+
default: '90d',
|
|
22
|
+
}),
|
|
23
|
+
pipeline: Flags.integer({
|
|
24
|
+
description: 'Pipeline ID (required when the account has several)',
|
|
25
|
+
}),
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async run() {
|
|
29
|
+
const { flags } = await this.parse(FunnelCommand)
|
|
30
|
+
const now = new Date()
|
|
31
|
+
const since = parsePeriod(flags.period, now)
|
|
32
|
+
|
|
33
|
+
let pipelineId = flags.pipeline
|
|
34
|
+
if (pipelineId == null) {
|
|
35
|
+
const body = await this.apiClient.get('/api/v2/pipelines')
|
|
36
|
+
const pipelines = body.data ?? []
|
|
37
|
+
if (pipelines.length > 1) {
|
|
38
|
+
throw new CliError(
|
|
39
|
+
`Account has ${pipelines.length} pipelines — pass --pipeline <id> ` +
|
|
40
|
+
`(${pipelines.map((p) => `${p.id}=${p.name}`).join(', ')})`,
|
|
41
|
+
{ exitCode: 64 },
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
pipelineId = pipelines[0]?.id
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const base = { pipeline_id: pipelineId, limit: 500 }
|
|
48
|
+
const [stages, open, won, lost] = await Promise.all([
|
|
49
|
+
collectPages(
|
|
50
|
+
this.apiClient.pageV2('/api/v2/stages', {
|
|
51
|
+
pipeline_id: pipelineId,
|
|
52
|
+
limit: 500,
|
|
53
|
+
}),
|
|
54
|
+
),
|
|
55
|
+
collectPages(
|
|
56
|
+
this.apiClient.pageV2('/api/v2/deals', { ...base, status: 'open' }),
|
|
57
|
+
),
|
|
58
|
+
collectPages(
|
|
59
|
+
this.apiClient.pageV2('/api/v2/deals', {
|
|
60
|
+
...base,
|
|
61
|
+
status: 'won',
|
|
62
|
+
updated_since: formatApiDatetime(since),
|
|
63
|
+
}),
|
|
64
|
+
),
|
|
65
|
+
collectPages(
|
|
66
|
+
this.apiClient.pageV2('/api/v2/deals', {
|
|
67
|
+
...base,
|
|
68
|
+
status: 'lost',
|
|
69
|
+
updated_since: formatApiDatetime(since),
|
|
70
|
+
}),
|
|
71
|
+
),
|
|
72
|
+
])
|
|
73
|
+
|
|
74
|
+
const funnel = computeFunnel([...won, ...lost], open, stages, {
|
|
75
|
+
pipelineId,
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
await this.outputResults(funnel, {
|
|
79
|
+
stage: { header: 'Stage' },
|
|
80
|
+
reached: { header: `Reached (closed, ${flags.period})` },
|
|
81
|
+
conversionFromPrev: {
|
|
82
|
+
header: 'Conv. from prev',
|
|
83
|
+
get: (row) =>
|
|
84
|
+
row.conversionFromPrev == null
|
|
85
|
+
? ''
|
|
86
|
+
: `${(row.conversionFromPrev * 100).toFixed(0)}%`,
|
|
87
|
+
},
|
|
88
|
+
openCount: { header: 'Open now' },
|
|
89
|
+
openValue: { header: 'Open value' },
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Flags } from '@oclif/core'
|
|
2
|
+
import BaseCommand from '../../base-command.js'
|
|
3
|
+
import { collectPages } from '../../lib/pagination.js'
|
|
4
|
+
import { parsePeriod, formatApiDatetime } from '../../lib/period.js'
|
|
5
|
+
import { computeVelocity } from '../../lib/analytics.js'
|
|
6
|
+
|
|
7
|
+
export default class MetricsVelocityCommand extends BaseCommand {
|
|
8
|
+
static description =
|
|
9
|
+
'Sales Velocity Equation: (open × win rate × avg won value) / cycle days'
|
|
10
|
+
|
|
11
|
+
static examples = [
|
|
12
|
+
'<%= config.bin %> metrics velocity',
|
|
13
|
+
'<%= config.bin %> metrics velocity --period 30d --pipeline 1',
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
static flags = {
|
|
17
|
+
...BaseCommand.baseFlags,
|
|
18
|
+
period: Flags.string({
|
|
19
|
+
description: 'Trailing window for closed deals (Nd or Nm)',
|
|
20
|
+
default: '90d',
|
|
21
|
+
}),
|
|
22
|
+
pipeline: Flags.integer({ description: 'Restrict to a pipeline ID' }),
|
|
23
|
+
owner: Flags.integer({ description: 'Restrict to an owner (user) ID' }),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async run() {
|
|
27
|
+
const { flags } = await this.parse(MetricsVelocityCommand)
|
|
28
|
+
const now = new Date()
|
|
29
|
+
const since = parsePeriod(flags.period, now)
|
|
30
|
+
|
|
31
|
+
const base = {
|
|
32
|
+
pipeline_id: flags.pipeline,
|
|
33
|
+
owner_id: flags.owner,
|
|
34
|
+
limit: 500,
|
|
35
|
+
}
|
|
36
|
+
const [open, won, lost] = await Promise.all([
|
|
37
|
+
collectPages(
|
|
38
|
+
this.apiClient.pageV2('/api/v2/deals', { ...base, status: 'open' }),
|
|
39
|
+
),
|
|
40
|
+
collectPages(
|
|
41
|
+
this.apiClient.pageV2('/api/v2/deals', {
|
|
42
|
+
...base,
|
|
43
|
+
status: 'won',
|
|
44
|
+
updated_since: formatApiDatetime(since),
|
|
45
|
+
}),
|
|
46
|
+
),
|
|
47
|
+
collectPages(
|
|
48
|
+
this.apiClient.pageV2('/api/v2/deals', {
|
|
49
|
+
...base,
|
|
50
|
+
status: 'lost',
|
|
51
|
+
updated_since: formatApiDatetime(since),
|
|
52
|
+
}),
|
|
53
|
+
),
|
|
54
|
+
])
|
|
55
|
+
|
|
56
|
+
const velocity = computeVelocity([...open, ...won, ...lost], { since, now })
|
|
57
|
+
|
|
58
|
+
if (this.resolveFormat() === 'table') {
|
|
59
|
+
const fmt = (v, digits = 1) => (v == null ? 'n/a' : v.toFixed(digits))
|
|
60
|
+
await this.outputResults(
|
|
61
|
+
[
|
|
62
|
+
{ metric: 'Open opportunities', value: String(velocity.openCount) },
|
|
63
|
+
{
|
|
64
|
+
metric: `Win rate (${flags.period})`,
|
|
65
|
+
value:
|
|
66
|
+
velocity.winRate == null
|
|
67
|
+
? 'n/a'
|
|
68
|
+
: `${(velocity.winRate * 100).toFixed(1)}% (${velocity.wonCount}W/${velocity.lostCount}L)`,
|
|
69
|
+
},
|
|
70
|
+
{ metric: 'Avg won value', value: fmt(velocity.avgWonValue, 0) },
|
|
71
|
+
{ metric: 'Avg cycle (days)', value: fmt(velocity.avgCycleDays) },
|
|
72
|
+
{ metric: 'Velocity / day', value: fmt(velocity.velocityPerDay, 0) },
|
|
73
|
+
],
|
|
74
|
+
{ metric: { header: 'Metric' }, value: { header: 'Value' } },
|
|
75
|
+
)
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
await this.outputResults(velocity, {})
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Flags } from '@oclif/core'
|
|
2
|
+
import BaseCommand from '../../base-command.js'
|
|
3
|
+
import { collectPages } from '../../lib/pagination.js'
|
|
4
|
+
import { computeHealth } from '../../lib/analytics.js'
|
|
5
|
+
import { CliError } from '../../lib/errors.js'
|
|
6
|
+
|
|
7
|
+
export default class PipelineHealthCommand extends BaseCommand {
|
|
8
|
+
static description =
|
|
9
|
+
'Per-stage pipeline health: value, weighted value, stale deals, missing next steps'
|
|
10
|
+
|
|
11
|
+
static examples = [
|
|
12
|
+
'<%= config.bin %> pipeline health',
|
|
13
|
+
'<%= config.bin %> pipeline health --pipeline 1',
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
static flags = {
|
|
17
|
+
...BaseCommand.baseFlags,
|
|
18
|
+
pipeline: Flags.integer({
|
|
19
|
+
description: 'Pipeline ID (required when the account has several)',
|
|
20
|
+
}),
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async run() {
|
|
24
|
+
const { flags } = await this.parse(PipelineHealthCommand)
|
|
25
|
+
const now = new Date()
|
|
26
|
+
|
|
27
|
+
let pipelineId = flags.pipeline
|
|
28
|
+
if (pipelineId == null) {
|
|
29
|
+
const body = await this.apiClient.get('/api/v2/pipelines')
|
|
30
|
+
const pipelines = body.data ?? []
|
|
31
|
+
if (pipelines.length > 1) {
|
|
32
|
+
throw new CliError(
|
|
33
|
+
`Account has ${pipelines.length} pipelines — pass --pipeline <id> ` +
|
|
34
|
+
`(${pipelines.map((p) => `${p.id}=${p.name}`).join(', ')})`,
|
|
35
|
+
{ exitCode: 64 },
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
pipelineId = pipelines[0]?.id
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const [stages, open, activities] = await Promise.all([
|
|
42
|
+
collectPages(
|
|
43
|
+
this.apiClient.pageV2('/api/v2/stages', {
|
|
44
|
+
pipeline_id: pipelineId,
|
|
45
|
+
limit: 500,
|
|
46
|
+
}),
|
|
47
|
+
),
|
|
48
|
+
collectPages(
|
|
49
|
+
this.apiClient.pageV2('/api/v2/deals', {
|
|
50
|
+
pipeline_id: pipelineId,
|
|
51
|
+
status: 'open',
|
|
52
|
+
limit: 500,
|
|
53
|
+
}),
|
|
54
|
+
),
|
|
55
|
+
collectPages(
|
|
56
|
+
this.apiClient.pageV2('/api/v2/activities', {
|
|
57
|
+
done: false,
|
|
58
|
+
limit: 500,
|
|
59
|
+
}),
|
|
60
|
+
),
|
|
61
|
+
])
|
|
62
|
+
|
|
63
|
+
const rows = computeHealth(open, stages, activities, { now })
|
|
64
|
+
|
|
65
|
+
await this.outputResults(rows, {
|
|
66
|
+
stage: { header: 'Stage' },
|
|
67
|
+
openCount: { header: 'Open' },
|
|
68
|
+
openValue: { header: 'Value' },
|
|
69
|
+
weightedValue: {
|
|
70
|
+
header: 'Weighted',
|
|
71
|
+
get: (row) => String(Math.round(row.weightedValue)),
|
|
72
|
+
},
|
|
73
|
+
staleCount: { header: 'Stale >14d' },
|
|
74
|
+
noNextActivityCount: { header: 'No next step' },
|
|
75
|
+
pastCloseCount: { header: 'Past close' },
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
const DAY_MS = 86_400_000
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sales Velocity Equation over a trailing window:
|
|
5
|
+
* (open opportunities × win rate × avg won value) / avg cycle days.
|
|
6
|
+
* Win rate uses the decided-deals variant: won / (won + lost) closed in
|
|
7
|
+
* the window, keyed on won_time/lost_time. Cycle = won_time − add_time.
|
|
8
|
+
* @param {object[]} deals open + closed deals (closed filtered to window here)
|
|
9
|
+
* @param {{ since: Date, now: Date }} window
|
|
10
|
+
*/
|
|
11
|
+
export function computeVelocity(deals, { since }) {
|
|
12
|
+
const open = deals.filter((d) => d.status === 'open')
|
|
13
|
+
const wonInPeriod = deals.filter(
|
|
14
|
+
(d) => d.status === 'won' && inWindow(d.won_time, since),
|
|
15
|
+
)
|
|
16
|
+
const lostInPeriod = deals.filter(
|
|
17
|
+
(d) => d.status === 'lost' && inWindow(d.lost_time, since),
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
const decided = wonInPeriod.length + lostInPeriod.length
|
|
21
|
+
const winRate = decided > 0 ? wonInPeriod.length / decided : null
|
|
22
|
+
|
|
23
|
+
const wonValues = wonInPeriod.map((d) => d.value ?? 0)
|
|
24
|
+
const avgWonValue =
|
|
25
|
+
wonInPeriod.length > 0
|
|
26
|
+
? wonValues.reduce((a, b) => a + b, 0) / wonInPeriod.length
|
|
27
|
+
: null
|
|
28
|
+
|
|
29
|
+
const cycles = wonInPeriod.map(
|
|
30
|
+
(d) => (new Date(d.won_time) - new Date(d.add_time)) / DAY_MS,
|
|
31
|
+
)
|
|
32
|
+
const avgCycleDays =
|
|
33
|
+
cycles.length > 0 ? cycles.reduce((a, b) => a + b, 0) / cycles.length : null
|
|
34
|
+
|
|
35
|
+
const velocityPerDay =
|
|
36
|
+
winRate != null && avgWonValue != null && avgCycleDays > 0
|
|
37
|
+
? (open.length * winRate * avgWonValue) / avgCycleDays
|
|
38
|
+
: null
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
openCount: open.length,
|
|
42
|
+
wonCount: wonInPeriod.length,
|
|
43
|
+
lostCount: lostInPeriod.length,
|
|
44
|
+
winRate,
|
|
45
|
+
avgWonValue,
|
|
46
|
+
avgCycleDays,
|
|
47
|
+
velocityPerDay,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function inWindow(time, since) {
|
|
52
|
+
return time != null && new Date(time) >= since
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Stage-reach funnel approximated from closed deals' final stages: a deal
|
|
57
|
+
* counts as having reached every stage up to (and including) its final one;
|
|
58
|
+
* won deals count for every stage. Accurate per-stage history would need
|
|
59
|
+
* per-deal flow mining (v1) — this stays one cheap list call.
|
|
60
|
+
* @param {object[]} closedDeals won/lost deals (already window-filtered)
|
|
61
|
+
* @param {object[]} openDeals current open deals
|
|
62
|
+
* @param {object[]} stages v2 stages (order_nr, pipeline_id)
|
|
63
|
+
* @param {{ pipelineId?: number }} [options]
|
|
64
|
+
*/
|
|
65
|
+
export function computeFunnel(closedDeals, openDeals, stages, options = {}) {
|
|
66
|
+
const ordered = stages
|
|
67
|
+
.filter(
|
|
68
|
+
(s) => options.pipelineId == null || s.pipeline_id === options.pipelineId,
|
|
69
|
+
)
|
|
70
|
+
.sort((a, b) => a.order_nr - b.order_nr)
|
|
71
|
+
|
|
72
|
+
const orderByStageId = new Map(ordered.map((s, i) => [s.id, i]))
|
|
73
|
+
|
|
74
|
+
function finalIndex(deal) {
|
|
75
|
+
if (deal.status === 'won') return ordered.length - 1
|
|
76
|
+
return orderByStageId.get(deal.stage_id) ?? -1
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return ordered.map((stage, index) => {
|
|
80
|
+
const reached = closedDeals.filter((d) => finalIndex(d) >= index).length
|
|
81
|
+
const openHere = openDeals.filter((d) => d.stage_id === stage.id)
|
|
82
|
+
const prevReached =
|
|
83
|
+
index > 0
|
|
84
|
+
? closedDeals.filter((d) => finalIndex(d) >= index - 1).length
|
|
85
|
+
: null
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
stage: stage.name,
|
|
89
|
+
stageId: stage.id,
|
|
90
|
+
reached,
|
|
91
|
+
conversionFromPrev:
|
|
92
|
+
index > 0 && prevReached > 0 ? reached / prevReached : null,
|
|
93
|
+
openCount: openHere.length,
|
|
94
|
+
openValue: openHere.reduce((sum, d) => sum + (d.value ?? 0), 0),
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const STALE_DAYS = 14
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Per-stage pipeline health snapshot: open count/value, probability-weighted
|
|
103
|
+
* value (deal probability wins over stage default), stale deals (>14 days
|
|
104
|
+
* without update), deals without a future open activity, and deals past
|
|
105
|
+
* their expected close date.
|
|
106
|
+
* @param {object[]} deals open deals
|
|
107
|
+
* @param {object[]} stages
|
|
108
|
+
* @param {object[]} activities open+done activities for those deals
|
|
109
|
+
* @param {{ now: Date }} options
|
|
110
|
+
*/
|
|
111
|
+
export function computeHealth(deals, stages, activities, { now }) {
|
|
112
|
+
const open = deals.filter((d) => d.status === 'open')
|
|
113
|
+
const today = now.toISOString().slice(0, 10)
|
|
114
|
+
|
|
115
|
+
const dealsWithFutureActivity = new Set(
|
|
116
|
+
activities
|
|
117
|
+
.filter((a) => !a.done && a.due_date >= today && a.deal_id != null)
|
|
118
|
+
.map((a) => a.deal_id),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
const ordered = [...stages].sort((a, b) => a.order_nr - b.order_nr)
|
|
122
|
+
|
|
123
|
+
return ordered.map((stage) => {
|
|
124
|
+
const inStage = open.filter((d) => d.stage_id === stage.id)
|
|
125
|
+
|
|
126
|
+
const weightedValue = inStage.reduce((sum, d) => {
|
|
127
|
+
const probability = d.probability ?? stage.deal_probability ?? 100
|
|
128
|
+
return sum + ((d.value ?? 0) * probability) / 100
|
|
129
|
+
}, 0)
|
|
130
|
+
|
|
131
|
+
const staleCount = inStage.filter(
|
|
132
|
+
(d) => now - new Date(d.update_time) > STALE_DAYS * DAY_MS,
|
|
133
|
+
).length
|
|
134
|
+
|
|
135
|
+
const noNextActivityCount = inStage.filter(
|
|
136
|
+
(d) => !dealsWithFutureActivity.has(d.id),
|
|
137
|
+
).length
|
|
138
|
+
|
|
139
|
+
const pastCloseCount = inStage.filter(
|
|
140
|
+
(d) => d.expected_close_date != null && d.expected_close_date < today,
|
|
141
|
+
).length
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
stage: stage.name,
|
|
145
|
+
stageId: stage.id,
|
|
146
|
+
openCount: inStage.length,
|
|
147
|
+
openValue: inStage.reduce((sum, d) => sum + (d.value ?? 0), 0),
|
|
148
|
+
weightedValue,
|
|
149
|
+
staleCount,
|
|
150
|
+
noNextActivityCount,
|
|
151
|
+
pastCloseCount,
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
}
|