@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
package/package.json
CHANGED
|
@@ -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,131 @@
|
|
|
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 { resolveTargets, bulkRun } from '../../lib/bulk.js'
|
|
6
|
+
import { buildWriteBody } from '../../lib/input.js'
|
|
7
|
+
import { defsForFields } from '../../lib/entity-view.js'
|
|
8
|
+
import { confirmAction } from '../../lib/confirm.js'
|
|
9
|
+
import { CliError } from '../../lib/errors.js'
|
|
10
|
+
|
|
11
|
+
export default class DealBulkUpdateCommand extends BaseCommand {
|
|
12
|
+
static description =
|
|
13
|
+
'Update many deals at once (by --ids, a saved --filter, or ids piped on stdin)'
|
|
14
|
+
|
|
15
|
+
static examples = [
|
|
16
|
+
'<%= config.bin %> deal bulk-update --ids 1,2,3 --stage 5',
|
|
17
|
+
'<%= config.bin %> deal bulk-update --filter 9 --status won',
|
|
18
|
+
"<%= config.bin %> deal list --status open --jq '.[].id' | <%= config.bin %> deal bulk-update --owner 42",
|
|
19
|
+
'<%= config.bin %> deal bulk-update --filter 9 --stage 5 --dry-run',
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
static flags = {
|
|
23
|
+
...BaseCommand.baseFlags,
|
|
24
|
+
ids: Flags.string({
|
|
25
|
+
description: 'Comma-separated deal IDs',
|
|
26
|
+
exclusive: ['filter'],
|
|
27
|
+
}),
|
|
28
|
+
filter: Flags.integer({
|
|
29
|
+
description: 'Pipedrive saved filter ID to select deals',
|
|
30
|
+
exclusive: ['ids'],
|
|
31
|
+
}),
|
|
32
|
+
stage: Flags.integer({ description: 'Move to stage ID' }),
|
|
33
|
+
pipeline: Flags.integer({ description: 'Move to pipeline ID' }),
|
|
34
|
+
status: Flags.string({
|
|
35
|
+
description: 'Set status',
|
|
36
|
+
options: ['open', 'won', 'lost'],
|
|
37
|
+
}),
|
|
38
|
+
owner: Flags.integer({ description: 'Assign owner (user) ID' }),
|
|
39
|
+
field: Flags.string({
|
|
40
|
+
multiple: true,
|
|
41
|
+
description: 'Custom/standard field as "Name=Value" (repeatable)',
|
|
42
|
+
}),
|
|
43
|
+
body: Flags.string({ description: 'Raw JSON body to merge (flags win)' }),
|
|
44
|
+
'dry-run': Flags.boolean({
|
|
45
|
+
description: 'List the targets without updating anything',
|
|
46
|
+
default: false,
|
|
47
|
+
}),
|
|
48
|
+
yes: Flags.boolean({
|
|
49
|
+
char: 'y',
|
|
50
|
+
description: 'Skip the confirmation prompt',
|
|
51
|
+
default: false,
|
|
52
|
+
}),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async run() {
|
|
56
|
+
const { flags } = await this.parse(DealBulkUpdateCommand)
|
|
57
|
+
|
|
58
|
+
const body = buildWriteBody({
|
|
59
|
+
typed: {
|
|
60
|
+
stage_id: flags.stage,
|
|
61
|
+
pipeline_id: flags.pipeline,
|
|
62
|
+
status: flags.status,
|
|
63
|
+
owner_id: flags.owner,
|
|
64
|
+
},
|
|
65
|
+
fields: flags.field,
|
|
66
|
+
rawBody: flags.body,
|
|
67
|
+
defs: await defsForFields(this, 'deal', flags.field),
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
if (Object.keys(body).length === 0) {
|
|
71
|
+
throw new CliError(
|
|
72
|
+
'Nothing to update — pass at least one change flag, --field, or --body',
|
|
73
|
+
{ exitCode: 64 },
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const targets = await resolveTargets(
|
|
78
|
+
{ ids: flags.ids, filter: flags.filter },
|
|
79
|
+
this.apiClient,
|
|
80
|
+
'/api/v2/deals',
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
if (flags['dry-run']) {
|
|
84
|
+
this.log(
|
|
85
|
+
`Would update ${chalk.bold(targets.length)} deals: ${targets.join(', ')}`,
|
|
86
|
+
)
|
|
87
|
+
this.log(chalk.dim(`Change: ${JSON.stringify(body)}`))
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const ok = await confirmAction(
|
|
92
|
+
`Update ${targets.length} deals with ${JSON.stringify(body)}?`,
|
|
93
|
+
flags.yes,
|
|
94
|
+
)
|
|
95
|
+
if (!ok) {
|
|
96
|
+
throw new CliError('Aborted', { exitCode: 1 })
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const spinner = ora(`Updating ${targets.length} deals...`).start()
|
|
100
|
+
let summary
|
|
101
|
+
try {
|
|
102
|
+
summary = await bulkRun(
|
|
103
|
+
targets,
|
|
104
|
+
(id) => this.apiClient.patch(`/api/v2/deals/${id}`, { body }),
|
|
105
|
+
{
|
|
106
|
+
onProgress: (done, total) => {
|
|
107
|
+
spinner.text = `Updating deals ${done}/${total}`
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
)
|
|
111
|
+
} finally {
|
|
112
|
+
spinner.stop()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
this.log(
|
|
116
|
+
chalk.green(
|
|
117
|
+
`Updated ${summary.succeeded.length}/${targets.length} deals`,
|
|
118
|
+
),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
if (summary.failed.length > 0) {
|
|
122
|
+
for (const { item, error } of summary.failed) {
|
|
123
|
+
this.log(chalk.red(` ✘ deal ${item}: ${error}`))
|
|
124
|
+
}
|
|
125
|
+
throw new CliError(
|
|
126
|
+
`${summary.failed.length} of ${targets.length} updates failed`,
|
|
127
|
+
{ exitCode: 1 },
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -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,109 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { Args, Flags } from '@oclif/core'
|
|
3
|
+
import chalk from 'chalk'
|
|
4
|
+
import ora from 'ora'
|
|
5
|
+
import BaseCommand from '../../base-command.js'
|
|
6
|
+
import { parseCsv } from '../../lib/csv-parse.js'
|
|
7
|
+
import { prepareImportBodies } from '../../lib/import.js'
|
|
8
|
+
import { bulkRun } from '../../lib/bulk.js'
|
|
9
|
+
import { getFields } from '../../lib/fields.js'
|
|
10
|
+
import { confirmAction } from '../../lib/confirm.js'
|
|
11
|
+
import { CliError } from '../../lib/errors.js'
|
|
12
|
+
|
|
13
|
+
const SPECIAL_COLUMNS = {
|
|
14
|
+
name: (typed, value) => {
|
|
15
|
+
typed.name = value
|
|
16
|
+
},
|
|
17
|
+
owner_id: (typed, value) => {
|
|
18
|
+
typed.owner_id = Number(value)
|
|
19
|
+
},
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default class OrgImportCommand extends BaseCommand {
|
|
23
|
+
static description =
|
|
24
|
+
'Bulk-create organizations from a CSV (headers map to fields, custom fields by name)'
|
|
25
|
+
|
|
26
|
+
static examples = [
|
|
27
|
+
'<%= config.bin %> org import orgs.csv',
|
|
28
|
+
'<%= config.bin %> org import orgs.csv --dry-run',
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
static args = {
|
|
32
|
+
file: Args.string({ required: true, description: 'CSV file path' }),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
static flags = {
|
|
36
|
+
...BaseCommand.baseFlags,
|
|
37
|
+
'dry-run': Flags.boolean({
|
|
38
|
+
description: 'Validate every row without creating anything',
|
|
39
|
+
default: false,
|
|
40
|
+
}),
|
|
41
|
+
yes: Flags.boolean({
|
|
42
|
+
char: 'y',
|
|
43
|
+
description: 'Skip the confirmation prompt',
|
|
44
|
+
default: false,
|
|
45
|
+
}),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async run() {
|
|
49
|
+
const { args, flags } = await this.parse(OrgImportCommand)
|
|
50
|
+
|
|
51
|
+
const { headers, rows } = parseCsv(readFileSync(args.file, 'utf8'))
|
|
52
|
+
if (!headers.some((h) => h.toLowerCase() === 'name')) {
|
|
53
|
+
throw new CliError('CSV must include a "name" column', { exitCode: 64 })
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const needsDefs = headers.some((h) => !(h.toLowerCase() in SPECIAL_COLUMNS))
|
|
57
|
+
const bodies = prepareImportBodies({
|
|
58
|
+
headers,
|
|
59
|
+
rows,
|
|
60
|
+
specialColumns: SPECIAL_COLUMNS,
|
|
61
|
+
defs: needsDefs ? await getFields(this.apiClient, 'org') : [],
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
if (flags['dry-run']) {
|
|
65
|
+
this.log(chalk.green(`${bodies.length} rows valid — nothing created`))
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const ok = await confirmAction(
|
|
70
|
+
`Create ${bodies.length} organizations from ${args.file}?`,
|
|
71
|
+
flags.yes,
|
|
72
|
+
)
|
|
73
|
+
if (!ok) {
|
|
74
|
+
throw new CliError('Aborted', { exitCode: 1 })
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const spinner = ora(`Importing ${bodies.length} organizations...`).start()
|
|
78
|
+
let summary
|
|
79
|
+
try {
|
|
80
|
+
summary = await bulkRun(
|
|
81
|
+
bodies,
|
|
82
|
+
(body) => this.apiClient.post('/api/v2/organizations', { body }),
|
|
83
|
+
{
|
|
84
|
+
onProgress: (done, total) => {
|
|
85
|
+
spinner.text = `Importing organizations ${done}/${total}`
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
)
|
|
89
|
+
} finally {
|
|
90
|
+
spinner.stop()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
this.log(
|
|
94
|
+
chalk.green(
|
|
95
|
+
`Imported ${summary.succeeded.length}/${bodies.length} organizations`,
|
|
96
|
+
),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
if (summary.failed.length > 0) {
|
|
100
|
+
for (const { item, error } of summary.failed) {
|
|
101
|
+
this.log(chalk.red(` ✘ ${item.name ?? '(unnamed)'}: ${error}`))
|
|
102
|
+
}
|
|
103
|
+
throw new CliError(
|
|
104
|
+
`${summary.failed.length} of ${bodies.length} rows failed`,
|
|
105
|
+
{ exitCode: 1 },
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { Args, Flags } from '@oclif/core'
|
|
3
|
+
import chalk from 'chalk'
|
|
4
|
+
import ora from 'ora'
|
|
5
|
+
import BaseCommand from '../../base-command.js'
|
|
6
|
+
import { parseCsv } from '../../lib/csv-parse.js'
|
|
7
|
+
import { prepareImportBodies } from '../../lib/import.js'
|
|
8
|
+
import { bulkRun } from '../../lib/bulk.js'
|
|
9
|
+
import { getFields } from '../../lib/fields.js'
|
|
10
|
+
import { confirmAction } from '../../lib/confirm.js'
|
|
11
|
+
import { CliError } from '../../lib/errors.js'
|
|
12
|
+
|
|
13
|
+
const SPECIAL_COLUMNS = {
|
|
14
|
+
name: (typed, value) => {
|
|
15
|
+
typed.name = value
|
|
16
|
+
},
|
|
17
|
+
email: (typed, value) => {
|
|
18
|
+
typed.emails = [{ value, primary: true }]
|
|
19
|
+
},
|
|
20
|
+
phone: (typed, value) => {
|
|
21
|
+
typed.phones = [{ value, primary: true }]
|
|
22
|
+
},
|
|
23
|
+
org_id: (typed, value) => {
|
|
24
|
+
typed.org_id = Number(value)
|
|
25
|
+
},
|
|
26
|
+
owner_id: (typed, value) => {
|
|
27
|
+
typed.owner_id = Number(value)
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export default class PersonImportCommand extends BaseCommand {
|
|
32
|
+
static description =
|
|
33
|
+
'Bulk-create persons from a CSV (headers map to fields, custom fields by name)'
|
|
34
|
+
|
|
35
|
+
static examples = [
|
|
36
|
+
'<%= config.bin %> person import people.csv',
|
|
37
|
+
'<%= config.bin %> person import people.csv --dry-run',
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
static args = {
|
|
41
|
+
file: Args.string({ required: true, description: 'CSV file path' }),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
static flags = {
|
|
45
|
+
...BaseCommand.baseFlags,
|
|
46
|
+
'dry-run': Flags.boolean({
|
|
47
|
+
description: 'Validate every row without creating anything',
|
|
48
|
+
default: false,
|
|
49
|
+
}),
|
|
50
|
+
yes: Flags.boolean({
|
|
51
|
+
char: 'y',
|
|
52
|
+
description: 'Skip the confirmation prompt',
|
|
53
|
+
default: false,
|
|
54
|
+
}),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async run() {
|
|
58
|
+
const { args, flags } = await this.parse(PersonImportCommand)
|
|
59
|
+
|
|
60
|
+
const { headers, rows } = parseCsv(readFileSync(args.file, 'utf8'))
|
|
61
|
+
if (!headers.some((h) => h.toLowerCase() === 'name')) {
|
|
62
|
+
throw new CliError('CSV must include a "name" column', { exitCode: 64 })
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const needsDefs = headers.some((h) => !(h.toLowerCase() in SPECIAL_COLUMNS))
|
|
66
|
+
const bodies = prepareImportBodies({
|
|
67
|
+
headers,
|
|
68
|
+
rows,
|
|
69
|
+
specialColumns: SPECIAL_COLUMNS,
|
|
70
|
+
defs: needsDefs ? await getFields(this.apiClient, 'person') : [],
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
if (flags['dry-run']) {
|
|
74
|
+
this.log(chalk.green(`${bodies.length} rows valid — nothing created`))
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const ok = await confirmAction(
|
|
79
|
+
`Create ${bodies.length} persons from ${args.file}?`,
|
|
80
|
+
flags.yes,
|
|
81
|
+
)
|
|
82
|
+
if (!ok) {
|
|
83
|
+
throw new CliError('Aborted', { exitCode: 1 })
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const spinner = ora(`Importing ${bodies.length} persons...`).start()
|
|
87
|
+
let summary
|
|
88
|
+
try {
|
|
89
|
+
summary = await bulkRun(
|
|
90
|
+
bodies,
|
|
91
|
+
(body) => this.apiClient.post('/api/v2/persons', { body }),
|
|
92
|
+
{
|
|
93
|
+
onProgress: (done, total) => {
|
|
94
|
+
spinner.text = `Importing persons ${done}/${total}`
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
)
|
|
98
|
+
} finally {
|
|
99
|
+
spinner.stop()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
this.log(
|
|
103
|
+
chalk.green(
|
|
104
|
+
`Imported ${summary.succeeded.length}/${bodies.length} persons`,
|
|
105
|
+
),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
if (summary.failed.length > 0) {
|
|
109
|
+
for (const { item, error } of summary.failed) {
|
|
110
|
+
this.log(chalk.red(` ✘ ${item.name ?? '(unnamed)'}: ${error}`))
|
|
111
|
+
}
|
|
112
|
+
throw new CliError(
|
|
113
|
+
`${summary.failed.length} of ${bodies.length} rows failed`,
|
|
114
|
+
{ exitCode: 1 },
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|