@wavyx/pdcli 0.4.0 → 0.5.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.
@@ -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
+ }