@wavyx/pdcli 0.3.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 +34 -0
- package/README.md +24 -0
- package/oclif.manifest.json +1003 -106
- package/package.json +1 -1
- package/src/commands/audit.js +137 -0
- package/src/commands/deal/bulk-update.js +131 -0
- package/src/commands/funnel.js +92 -0
- package/src/commands/metrics/velocity.js +81 -0
- package/src/commands/org/import.js +109 -0
- package/src/commands/person/import.js +118 -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/bulk.js +106 -0
- package/src/lib/csv-parse.js +88 -0
- package/src/lib/import.js +49 -0
- package/src/lib/period.js +33 -0
|
@@ -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
|
+
}
|
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
|
+
}
|
package/src/lib/bulk.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import createDebug from 'debug'
|
|
2
|
+
import { CliError } from './errors.js'
|
|
3
|
+
|
|
4
|
+
const debug = createDebug('pd:bulk')
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Resolve the target ids for a bulk operation from exactly one selector:
|
|
8
|
+
* --ids "1,2,3", a Pipedrive saved filter (--filter <id>), or piped stdin
|
|
9
|
+
* (newline-separated ids, a JSON array of ids, or JSON objects with an id).
|
|
10
|
+
* @param {object} selectors
|
|
11
|
+
* @param {string} [selectors.ids]
|
|
12
|
+
* @param {number} [selectors.filter]
|
|
13
|
+
* @param {NodeJS.ReadStream} [selectors.stdin] defaults to process.stdin
|
|
14
|
+
* @param {ReturnType<import('./client.js').createClient>} client
|
|
15
|
+
* @param {string} listPath v2 list endpoint supporting filter_id (e.g. /api/v2/deals)
|
|
16
|
+
* @returns {Promise<number[]>}
|
|
17
|
+
*/
|
|
18
|
+
export async function resolveTargets(
|
|
19
|
+
{ ids, filter, stdin = process.stdin },
|
|
20
|
+
client,
|
|
21
|
+
listPath,
|
|
22
|
+
) {
|
|
23
|
+
if (ids) {
|
|
24
|
+
return ids.split(',').map(parseId)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (filter != null) {
|
|
28
|
+
debug('resolving targets from filter %d', filter)
|
|
29
|
+
const targets = []
|
|
30
|
+
for await (const item of client.pageV2(listPath, {
|
|
31
|
+
filter_id: filter,
|
|
32
|
+
limit: 500,
|
|
33
|
+
})) {
|
|
34
|
+
targets.push(item.id)
|
|
35
|
+
}
|
|
36
|
+
return targets
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!stdin.isTTY) {
|
|
40
|
+
const chunks = []
|
|
41
|
+
for await (const chunk of stdin) chunks.push(chunk)
|
|
42
|
+
const text = Buffer.concat(chunks).toString('utf8').trim()
|
|
43
|
+
if (text.startsWith('[')) {
|
|
44
|
+
const parsed = JSON.parse(text)
|
|
45
|
+
return parsed.map((entry) =>
|
|
46
|
+
typeof entry === 'object' ? entry.id : parseId(String(entry)),
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
return text.split('\n').map(parseId)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
throw new CliError(
|
|
53
|
+
'No targets — pass --ids, --filter, or pipe ids on stdin',
|
|
54
|
+
{ exitCode: 64 },
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseId(raw) {
|
|
59
|
+
const id = Number(raw.trim())
|
|
60
|
+
if (!Number.isInteger(id)) {
|
|
61
|
+
throw new CliError(`Invalid id "${raw.trim()}" — expected an integer`, {
|
|
62
|
+
exitCode: 64,
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
return id
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function sleep(ms) {
|
|
69
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Run an async operation per item, sequentially with a pacing gap so bulk
|
|
74
|
+
* writes stay inside Pipedrive's 2-second burst window (writes cost 10
|
|
75
|
+
* tokens each; the client's 429 backoff covers anything that slips through).
|
|
76
|
+
* Per-item failures are collected, never thrown.
|
|
77
|
+
* @template T
|
|
78
|
+
* @param {T[]} items
|
|
79
|
+
* @param {(item: T) => Promise<unknown>} operation
|
|
80
|
+
* @param {object} [options]
|
|
81
|
+
* @param {number} [options.gapMs] delay between requests (default 200)
|
|
82
|
+
* @param {(done: number, total: number) => void} [options.onProgress]
|
|
83
|
+
* @returns {Promise<{ succeeded: { item: T, result: unknown }[], failed: { item: T, error: string }[] }>}
|
|
84
|
+
*/
|
|
85
|
+
export async function bulkRun(
|
|
86
|
+
items,
|
|
87
|
+
operation,
|
|
88
|
+
{ gapMs = 200, onProgress } = {},
|
|
89
|
+
) {
|
|
90
|
+
const succeeded = []
|
|
91
|
+
const failed = []
|
|
92
|
+
|
|
93
|
+
for (const [index, item] of items.entries()) {
|
|
94
|
+
if (index > 0 && gapMs > 0) await sleep(gapMs)
|
|
95
|
+
try {
|
|
96
|
+
const result = await operation(item)
|
|
97
|
+
succeeded.push({ item, result })
|
|
98
|
+
} catch (err) {
|
|
99
|
+
debug('bulk item %o failed: %s', item, err.message)
|
|
100
|
+
failed.push({ item, error: err.message })
|
|
101
|
+
}
|
|
102
|
+
onProgress?.(index + 1, items.length)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { succeeded, failed }
|
|
106
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { CliError } from './errors.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimal RFC 4180 CSV parser (quoted fields, escaped quotes, embedded
|
|
5
|
+
* commas/newlines, CRLF). First record is the header row.
|
|
6
|
+
* @param {string} text
|
|
7
|
+
* @returns {{ headers: string[], rows: string[][] }}
|
|
8
|
+
*/
|
|
9
|
+
export function parseCsv(text) {
|
|
10
|
+
const records = []
|
|
11
|
+
let record = []
|
|
12
|
+
let field = ''
|
|
13
|
+
let inQuotes = false
|
|
14
|
+
let i = 0
|
|
15
|
+
|
|
16
|
+
while (i < text.length) {
|
|
17
|
+
const char = text[i]
|
|
18
|
+
|
|
19
|
+
if (inQuotes) {
|
|
20
|
+
if (char === '"') {
|
|
21
|
+
if (text[i + 1] === '"') {
|
|
22
|
+
field += '"'
|
|
23
|
+
i += 2
|
|
24
|
+
continue
|
|
25
|
+
}
|
|
26
|
+
inQuotes = false
|
|
27
|
+
i++
|
|
28
|
+
continue
|
|
29
|
+
}
|
|
30
|
+
field += char
|
|
31
|
+
i++
|
|
32
|
+
continue
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (char === '"') {
|
|
36
|
+
inQuotes = true
|
|
37
|
+
i++
|
|
38
|
+
continue
|
|
39
|
+
}
|
|
40
|
+
if (char === ',') {
|
|
41
|
+
record.push(field)
|
|
42
|
+
field = ''
|
|
43
|
+
i++
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
if (char === '\n' || char === '\r') {
|
|
47
|
+
if (char === '\r' && text[i + 1] === '\n') i++
|
|
48
|
+
record.push(field)
|
|
49
|
+
field = ''
|
|
50
|
+
if (record.length > 1 || record[0] !== '') records.push(record)
|
|
51
|
+
record = []
|
|
52
|
+
i++
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
field += char
|
|
56
|
+
i++
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (inQuotes) {
|
|
60
|
+
throw new CliError('Unterminated quoted field in CSV', { exitCode: 65 })
|
|
61
|
+
}
|
|
62
|
+
if (field !== '' || record.length > 0) {
|
|
63
|
+
// A tail record only reaches here when non-empty (a bare trailing
|
|
64
|
+
// newline never starts a record), so push unconditionally.
|
|
65
|
+
record.push(field)
|
|
66
|
+
records.push(record)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (records.length === 0) {
|
|
70
|
+
throw new CliError('CSV file is empty', { exitCode: 65 })
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const [headers, ...rows] = records
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
headers,
|
|
77
|
+
rows: rows.map((row, index) => {
|
|
78
|
+
if (row.length > headers.length) {
|
|
79
|
+
throw new CliError(
|
|
80
|
+
`CSV row ${index + 2} has ${row.length} cells but the header has ${headers.length}`,
|
|
81
|
+
{ exitCode: 65 },
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
while (row.length < headers.length) row.push('')
|
|
85
|
+
return row
|
|
86
|
+
}),
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { buildWriteBody } from './input.js'
|
|
2
|
+
import { CliError } from './errors.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Turn parsed CSV rows into write bodies. Special columns (matched
|
|
6
|
+
* case-insensitively) build typed values; every other header resolves
|
|
7
|
+
* through the entity's field definitions — names, hash keys, and option
|
|
8
|
+
* labels included. Empty cells are skipped.
|
|
9
|
+
* @param {object} options
|
|
10
|
+
* @param {string[]} options.headers
|
|
11
|
+
* @param {string[][]} options.rows
|
|
12
|
+
* @param {Record<string, (typed: object, value: string) => void>} [options.specialColumns]
|
|
13
|
+
* @param {object[]} [options.defs] field definitions for non-special headers
|
|
14
|
+
* @returns {object[]} one request body per row
|
|
15
|
+
*/
|
|
16
|
+
export function prepareImportBodies({
|
|
17
|
+
headers,
|
|
18
|
+
rows,
|
|
19
|
+
specialColumns = {},
|
|
20
|
+
defs,
|
|
21
|
+
}) {
|
|
22
|
+
const specials = Object.fromEntries(
|
|
23
|
+
Object.entries(specialColumns).map(([k, v]) => [k.toLowerCase(), v]),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
return rows.map((row, index) => {
|
|
27
|
+
const typed = {}
|
|
28
|
+
const fields = []
|
|
29
|
+
|
|
30
|
+
headers.forEach((header, i) => {
|
|
31
|
+
const value = row[i]
|
|
32
|
+
if (value === '') return
|
|
33
|
+
const special = specials[header.toLowerCase()]
|
|
34
|
+
if (special) {
|
|
35
|
+
special(typed, value)
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
fields.push(`${header}=${value}`)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
return buildWriteBody({ typed, fields, defs })
|
|
43
|
+
} catch (err) {
|
|
44
|
+
throw new CliError(`CSV row ${index + 2}: ${err.message}`, {
|
|
45
|
+
exitCode: 65,
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
}
|