@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.
- package/CHANGELOG.md +17 -0
- package/README.md +10 -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
package/src/lib/audit.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
const DAY_MS = 86_400_000
|
|
2
|
+
const STALE_DAYS = 14
|
|
3
|
+
const ANCIENT_FALLBACK_DAYS = 168 // ~2× the median B2B cycle
|
|
4
|
+
const ANCIENT_CYCLE_MULTIPLIER = 2
|
|
5
|
+
|
|
6
|
+
function openDeals(data) {
|
|
7
|
+
return data.deals.filter((d) => d.status === 'open')
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function today(now) {
|
|
11
|
+
return now.toISOString().slice(0, 10)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Data-quality checks. Each returns flagged items; detection rules follow
|
|
16
|
+
* common RevOps hygiene practice (see docs for thresholds).
|
|
17
|
+
* @type {{ name: string, severity: 'must' | 'should', title: string, run: (data: object, ctx: { now: Date }) => object[] }[]}
|
|
18
|
+
*/
|
|
19
|
+
export const AUDIT_CHECKS = [
|
|
20
|
+
{
|
|
21
|
+
name: 'stale-deals',
|
|
22
|
+
severity: 'must',
|
|
23
|
+
title: `Open deals untouched for >${STALE_DAYS} days`,
|
|
24
|
+
run: (data, { now }) =>
|
|
25
|
+
openDeals(data)
|
|
26
|
+
.filter((d) => now - new Date(d.update_time) > STALE_DAYS * DAY_MS)
|
|
27
|
+
.map((d) => ({
|
|
28
|
+
id: d.id,
|
|
29
|
+
title: d.title,
|
|
30
|
+
days: Math.floor((now - new Date(d.update_time)) / DAY_MS),
|
|
31
|
+
})),
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: 'no-next-activity',
|
|
35
|
+
severity: 'must',
|
|
36
|
+
title: 'Open deals with no future activity scheduled',
|
|
37
|
+
run: (data, { now }) => {
|
|
38
|
+
const withFuture = new Set(
|
|
39
|
+
data.activities
|
|
40
|
+
.filter(
|
|
41
|
+
(a) => !a.done && a.due_date >= today(now) && a.deal_id != null,
|
|
42
|
+
)
|
|
43
|
+
.map((a) => a.deal_id),
|
|
44
|
+
)
|
|
45
|
+
return openDeals(data)
|
|
46
|
+
.filter((d) => !withFuture.has(d.id))
|
|
47
|
+
.map((d) => ({ id: d.id, title: d.title }))
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: 'past-close-date',
|
|
52
|
+
severity: 'must',
|
|
53
|
+
title: 'Open deals past their expected close date',
|
|
54
|
+
run: (data, { now }) =>
|
|
55
|
+
openDeals(data)
|
|
56
|
+
.filter(
|
|
57
|
+
(d) =>
|
|
58
|
+
d.expected_close_date != null && d.expected_close_date < today(now),
|
|
59
|
+
)
|
|
60
|
+
.map((d) => ({
|
|
61
|
+
id: d.id,
|
|
62
|
+
title: d.title,
|
|
63
|
+
expected_close_date: d.expected_close_date,
|
|
64
|
+
})),
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: 'missing-fields',
|
|
68
|
+
severity: 'must',
|
|
69
|
+
title: 'Open deals missing owner, person/org, value, or currency',
|
|
70
|
+
run: (data) =>
|
|
71
|
+
openDeals(data)
|
|
72
|
+
.map((d) => {
|
|
73
|
+
const missing = []
|
|
74
|
+
if (d.owner_id == null) missing.push('owner')
|
|
75
|
+
if (d.person_id == null && d.org_id == null)
|
|
76
|
+
missing.push('person/org')
|
|
77
|
+
if (d.value == null || d.value <= 0) missing.push('value')
|
|
78
|
+
else if (!d.currency) missing.push('currency')
|
|
79
|
+
return { id: d.id, title: d.title, missing }
|
|
80
|
+
})
|
|
81
|
+
.filter((item) => item.missing.length > 0),
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: 'ancient-deals',
|
|
85
|
+
severity: 'must',
|
|
86
|
+
title: 'Open deals far older than the typical won cycle',
|
|
87
|
+
run: (data, { now }) => {
|
|
88
|
+
const cycles = data.deals
|
|
89
|
+
.filter((d) => d.status === 'won' && d.won_time && d.add_time)
|
|
90
|
+
.map((d) => (new Date(d.won_time) - new Date(d.add_time)) / DAY_MS)
|
|
91
|
+
const avgCycle =
|
|
92
|
+
cycles.length > 0
|
|
93
|
+
? cycles.reduce((a, b) => a + b, 0) / cycles.length
|
|
94
|
+
: null
|
|
95
|
+
const thresholdDays =
|
|
96
|
+
avgCycle != null
|
|
97
|
+
? avgCycle * ANCIENT_CYCLE_MULTIPLIER
|
|
98
|
+
: ANCIENT_FALLBACK_DAYS
|
|
99
|
+
return openDeals(data)
|
|
100
|
+
.filter((d) => now - new Date(d.add_time) > thresholdDays * DAY_MS)
|
|
101
|
+
.map((d) => ({
|
|
102
|
+
id: d.id,
|
|
103
|
+
title: d.title,
|
|
104
|
+
ageDays: Math.floor((now - new Date(d.add_time)) / DAY_MS),
|
|
105
|
+
thresholdDays: Math.round(thresholdDays),
|
|
106
|
+
}))
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
name: 'missing-close-time',
|
|
111
|
+
severity: 'should',
|
|
112
|
+
title: 'Closed deals missing their close timestamp',
|
|
113
|
+
run: (data) =>
|
|
114
|
+
data.deals
|
|
115
|
+
.filter(
|
|
116
|
+
(d) =>
|
|
117
|
+
(d.status === 'won' && d.won_time == null) ||
|
|
118
|
+
(d.status === 'lost' && d.lost_time == null),
|
|
119
|
+
)
|
|
120
|
+
.map((d) => ({ id: d.id, title: d.title, status: d.status })),
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
name: 'duplicate-persons',
|
|
124
|
+
severity: 'must',
|
|
125
|
+
title: 'Persons sharing the same email',
|
|
126
|
+
run: (data) => {
|
|
127
|
+
const byEmail = new Map()
|
|
128
|
+
for (const person of data.persons) {
|
|
129
|
+
for (const entry of person.emails ?? []) {
|
|
130
|
+
const email = entry.value?.trim().toLowerCase()
|
|
131
|
+
if (!email) continue
|
|
132
|
+
byEmail.set(email, [...(byEmail.get(email) ?? []), person.id])
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return [...byEmail.entries()]
|
|
136
|
+
.filter(([, ids]) => new Set(ids).size > 1)
|
|
137
|
+
.map(([email, ids]) => ({ email, ids: [...new Set(ids)] }))
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
name: 'uncontactable-persons',
|
|
142
|
+
severity: 'must',
|
|
143
|
+
title: 'Persons with neither email nor phone',
|
|
144
|
+
run: (data) =>
|
|
145
|
+
data.persons
|
|
146
|
+
.filter(
|
|
147
|
+
(p) =>
|
|
148
|
+
!(p.emails ?? []).some((e) => e.value) &&
|
|
149
|
+
!(p.phones ?? []).some((ph) => ph.value),
|
|
150
|
+
)
|
|
151
|
+
.map((p) => ({ id: p.id, name: p.name })),
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
name: 'duplicate-orgs',
|
|
155
|
+
severity: 'should',
|
|
156
|
+
title: 'Organizations with the same normalized name',
|
|
157
|
+
run: (data) => {
|
|
158
|
+
const byName = new Map()
|
|
159
|
+
for (const org of data.organizations) {
|
|
160
|
+
const key = normalizeOrgName(org.name)
|
|
161
|
+
if (!key) continue
|
|
162
|
+
byName.set(key, [...(byName.get(key) ?? []), org.id])
|
|
163
|
+
}
|
|
164
|
+
return [...byName.entries()]
|
|
165
|
+
.filter(([, ids]) => ids.length > 1)
|
|
166
|
+
.map(([name, ids]) => ({ name, ids }))
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
name: 'overdue-activities',
|
|
171
|
+
severity: 'should',
|
|
172
|
+
title: 'Overdue open activities piling up per owner',
|
|
173
|
+
run: (data, { now }) => {
|
|
174
|
+
const byOwner = new Map()
|
|
175
|
+
for (const activity of data.activities) {
|
|
176
|
+
if (activity.done || activity.due_date >= today(now)) continue
|
|
177
|
+
byOwner.set(
|
|
178
|
+
activity.owner_id,
|
|
179
|
+
(byOwner.get(activity.owner_id) ?? 0) + 1,
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
return [...byOwner.entries()].map(([owner_id, overdue]) => ({
|
|
183
|
+
owner_id,
|
|
184
|
+
overdue,
|
|
185
|
+
}))
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
name: 'currency-missing',
|
|
190
|
+
severity: 'should',
|
|
191
|
+
title: 'Deals with a value but no currency',
|
|
192
|
+
run: (data) =>
|
|
193
|
+
openDeals(data)
|
|
194
|
+
.filter((d) => d.value != null && d.value > 0 && !d.currency)
|
|
195
|
+
.map((d) => ({ id: d.id, title: d.title, value: d.value })),
|
|
196
|
+
},
|
|
197
|
+
]
|
|
198
|
+
|
|
199
|
+
function normalizeOrgName(name) {
|
|
200
|
+
return (name ?? '')
|
|
201
|
+
.toLowerCase()
|
|
202
|
+
.replace(/\b(inc|ltd|llc|gmbh|sa|sarl|bv|corp|co)\b\.?/g, '')
|
|
203
|
+
.replace(/[^a-z0-9]/g, '')
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Run all (or a subset of) hygiene checks over pre-fetched account data.
|
|
208
|
+
* @param {{ deals: object[], persons: object[], organizations: object[], activities: object[] }} data
|
|
209
|
+
* @param {{ now: Date, only?: string[] }} options
|
|
210
|
+
* @returns {{ name: string, severity: string, title: string, count: number, items: object[] }[]}
|
|
211
|
+
*/
|
|
212
|
+
export function runChecks(data, { now, only } = {}) {
|
|
213
|
+
return AUDIT_CHECKS.filter((check) => !only || only.includes(check.name)).map(
|
|
214
|
+
(check) => {
|
|
215
|
+
const overdueTotal = check.name === 'overdue-activities'
|
|
216
|
+
const items = check.run(data, { now })
|
|
217
|
+
return {
|
|
218
|
+
name: check.name,
|
|
219
|
+
severity: check.severity,
|
|
220
|
+
title: check.title,
|
|
221
|
+
count: overdueTotal
|
|
222
|
+
? items.reduce((sum, i) => sum + i.overdue, 0)
|
|
223
|
+
: items.length,
|
|
224
|
+
items,
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
)
|
|
228
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { CliError } from './errors.js'
|
|
2
|
+
|
|
3
|
+
const DAY_MS = 86_400_000
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Parse a trailing period like "90d", "30d", or "3m" (months = 30 days)
|
|
7
|
+
* into the start date of the window.
|
|
8
|
+
* @param {string} period
|
|
9
|
+
* @param {Date} [now]
|
|
10
|
+
* @returns {Date}
|
|
11
|
+
*/
|
|
12
|
+
export function parsePeriod(period, now = new Date()) {
|
|
13
|
+
const match = /^(\d+)([dm])$/.exec(period)
|
|
14
|
+
if (!match) {
|
|
15
|
+
throw new CliError(
|
|
16
|
+
`Invalid period "${period}" — use Nd (days) or Nm (months), e.g. 90d`,
|
|
17
|
+
{ exitCode: 64 },
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
const amount = Number(match[1])
|
|
21
|
+
const days = match[2] === 'm' ? amount * 30 : amount
|
|
22
|
+
return new Date(now.getTime() - days * DAY_MS)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Format a date the way v2 query params accept it: RFC 3339 seconds
|
|
27
|
+
* precision, no milliseconds (the API rejects fractional seconds).
|
|
28
|
+
* @param {Date} date
|
|
29
|
+
* @returns {string}
|
|
30
|
+
*/
|
|
31
|
+
export function formatApiDatetime(date) {
|
|
32
|
+
return date.toISOString().replace(/\.\d{3}Z$/, 'Z')
|
|
33
|
+
}
|