@wavyx/pdcli 0.7.0 → 0.8.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 +43 -0
- package/README.md +8 -0
- package/oclif.manifest.json +2598 -925
- package/package.json +8 -1
- package/src/base-command.js +12 -2
- package/src/commands/alias/list.js +31 -0
- package/src/commands/alias/set.js +97 -0
- package/src/commands/alias/unset.js +26 -0
- package/src/commands/config/set.js +14 -0
- package/src/commands/config/unset.js +32 -0
- package/src/commands/file/remote-link.js +56 -0
- package/src/commands/funnel.js +97 -2
- package/src/commands/lead/label/list.js +27 -0
- package/src/commands/metrics/coverage.js +251 -0
- package/src/commands/org/merge.js +97 -0
- package/src/commands/person/merge.js +91 -0
- package/src/hooks/command-not-found.js +68 -0
- package/src/lib/aliases.js +35 -0
- package/src/lib/analytics.js +142 -0
- package/src/lib/audit.js +107 -6
- package/src/lib/client.js +30 -0
- package/src/lib/confirm.js +15 -2
- package/src/lib/entity-view.js +15 -9
- package/src/lib/fields.js +4 -1
package/src/lib/analytics.js
CHANGED
|
@@ -96,6 +96,90 @@ export function computeFunnel(closedDeals, openDeals, stages, options = {}) {
|
|
|
96
96
|
})
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
/**
|
|
100
|
+
* EXACT stage funnel mined from per-deal changelog history, rather than the
|
|
101
|
+
* final-stage approximation in computeFunnel. A deal "enters" a stage when it
|
|
102
|
+
* is observed there: every `new_value` of a stage_id transition, plus the
|
|
103
|
+
* deal's starting stage (the first transition's `old_value`, or the deal's
|
|
104
|
+
* current stage when it has no stage transitions — i.e. it was created
|
|
105
|
+
* directly in that stage). Skipped stages are NOT counted (the whole point):
|
|
106
|
+
* a deal that jumps 1→3 enters 1 and 3 only, never 2. Entries are de-duped per
|
|
107
|
+
* deal, so re-entering a stage still counts once. `won` counts deals whose
|
|
108
|
+
* changelog has a status transition to "won".
|
|
109
|
+
*
|
|
110
|
+
* @param {{ dealId: number, stageId: number, rows: object[] }[]} transitionsByDeal
|
|
111
|
+
* one entry per mined deal: its current stage_id and raw changelog rows
|
|
112
|
+
* (each row: { field_key, old_value, new_value } — values stringified).
|
|
113
|
+
* @param {object[]} stages v2 stages (order_nr, pipeline_id)
|
|
114
|
+
* @param {{ pipelineId?: number }} [options]
|
|
115
|
+
*/
|
|
116
|
+
export function computeExactFunnel(transitionsByDeal, stages, options = {}) {
|
|
117
|
+
const ordered = stages
|
|
118
|
+
.filter(
|
|
119
|
+
(s) => options.pipelineId == null || s.pipeline_id === options.pipelineId,
|
|
120
|
+
)
|
|
121
|
+
.sort((a, b) => a.order_nr - b.order_nr)
|
|
122
|
+
|
|
123
|
+
const validStageId = new Set(ordered.map((s) => s.id))
|
|
124
|
+
// distinct deals entered per stage id
|
|
125
|
+
const enteredByStage = new Map(ordered.map((s) => [s.id, new Set()]))
|
|
126
|
+
let won = 0
|
|
127
|
+
|
|
128
|
+
for (const { dealId, stageId, rows } of transitionsByDeal) {
|
|
129
|
+
const stageRows = rows.filter((r) => r.field_key === 'stage_id')
|
|
130
|
+
const entered = new Set()
|
|
131
|
+
|
|
132
|
+
// Starting stage: derived from the transition graph — the source stage
|
|
133
|
+
// that never appears as a destination. Order-independent, so it is
|
|
134
|
+
// immune to the changelog's newest-first ordering AND to two hops
|
|
135
|
+
// sharing the same one-second timestamp. Falls back to the oldest row's
|
|
136
|
+
// source (time sort) for degenerate cycles, else the current stage.
|
|
137
|
+
const destinations = new Set(stageRows.map((r) => String(r.new_value)))
|
|
138
|
+
const startSources = stageRows
|
|
139
|
+
.map((r) => String(r.old_value))
|
|
140
|
+
.filter((v) => !destinations.has(v))
|
|
141
|
+
let startStage
|
|
142
|
+
if (startSources.length > 0) {
|
|
143
|
+
startStage = Number(startSources[0])
|
|
144
|
+
} else if (stageRows.length > 0) {
|
|
145
|
+
const oldest = [...stageRows].sort((a, b) =>
|
|
146
|
+
String(a.time ?? '').localeCompare(String(b.time ?? '')),
|
|
147
|
+
)[0]
|
|
148
|
+
startStage = Number(oldest.old_value)
|
|
149
|
+
} else {
|
|
150
|
+
startStage = stageId
|
|
151
|
+
}
|
|
152
|
+
entered.add(startStage)
|
|
153
|
+
for (const r of stageRows) entered.add(Number(r.new_value))
|
|
154
|
+
|
|
155
|
+
for (const id of entered) {
|
|
156
|
+
if (validStageId.has(id)) enteredByStage.get(id).add(dealId)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (rows.some((r) => r.field_key === 'status' && r.new_value === 'won')) {
|
|
160
|
+
won++
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const resultRows = ordered.map((stage, index) => {
|
|
165
|
+
const entered = enteredByStage.get(stage.id).size
|
|
166
|
+
const prevEntered =
|
|
167
|
+
index > 0 ? enteredByStage.get(ordered[index - 1].id).size : null
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
stage: stage.name,
|
|
171
|
+
stageId: stage.id,
|
|
172
|
+
entered,
|
|
173
|
+
conversionFromPrev:
|
|
174
|
+
index > 0 && prevEntered > 0 ? entered / prevEntered : null,
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
// `won` is a single account-wide total — returned once, not repeated on
|
|
179
|
+
// every row, so JSON consumers don't misread it as a per-stage figure.
|
|
180
|
+
return { rows: resultRows, won }
|
|
181
|
+
}
|
|
182
|
+
|
|
99
183
|
const STALE_DAYS = 14
|
|
100
184
|
|
|
101
185
|
/**
|
|
@@ -152,3 +236,61 @@ export function computeHealth(deals, stages, activities, { now }) {
|
|
|
152
236
|
}
|
|
153
237
|
})
|
|
154
238
|
}
|
|
239
|
+
|
|
240
|
+
/** Rule-of-thumb pipeline-coverage thresholds (raw open ÷ remaining gap). */
|
|
241
|
+
const COVERAGE_HEALTHY = 3
|
|
242
|
+
const COVERAGE_BORDERLINE = 2
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Pipeline coverage against a revenue quota. The classic 3x rule of thumb is
|
|
246
|
+
* defined on RAW pipeline value — weighting it by win probability first would
|
|
247
|
+
* double-discount risk — so `coverage` = openValue ÷ remaining and drives the
|
|
248
|
+
* verdict; `weightedCoverage` = weightedOpen ÷ remaining is reported as the
|
|
249
|
+
* risk-adjusted secondary view. remaining = max(target − progress, 0).
|
|
250
|
+
*
|
|
251
|
+
* When progress already meets/exceeds the target the gap clamps to 0 — there
|
|
252
|
+
* is nothing left to cover, so the ratios are `null` (not Infinity, which is
|
|
253
|
+
* not JSON-serializable) with verdict `'covered'`.
|
|
254
|
+
* @param {{ openValue: number, weightedOpen: number, goalTarget: number,
|
|
255
|
+
* progress?: number }} input
|
|
256
|
+
*/
|
|
257
|
+
export function computeCoverage({
|
|
258
|
+
openValue,
|
|
259
|
+
weightedOpen,
|
|
260
|
+
goalTarget,
|
|
261
|
+
progress = 0,
|
|
262
|
+
}) {
|
|
263
|
+
const remaining = Math.max(goalTarget - progress, 0)
|
|
264
|
+
|
|
265
|
+
if (remaining === 0) {
|
|
266
|
+
return {
|
|
267
|
+
openValue,
|
|
268
|
+
weightedOpen,
|
|
269
|
+
goalTarget,
|
|
270
|
+
progress,
|
|
271
|
+
remaining,
|
|
272
|
+
coverage: null,
|
|
273
|
+
weightedCoverage: null,
|
|
274
|
+
verdict: 'covered',
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const coverage = openValue / remaining
|
|
279
|
+
const verdict =
|
|
280
|
+
coverage >= COVERAGE_HEALTHY
|
|
281
|
+
? 'healthy'
|
|
282
|
+
: coverage >= COVERAGE_BORDERLINE
|
|
283
|
+
? 'borderline'
|
|
284
|
+
: 'low'
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
openValue,
|
|
288
|
+
weightedOpen,
|
|
289
|
+
goalTarget,
|
|
290
|
+
progress,
|
|
291
|
+
remaining,
|
|
292
|
+
coverage,
|
|
293
|
+
weightedCoverage: weightedOpen / remaining,
|
|
294
|
+
verdict,
|
|
295
|
+
}
|
|
296
|
+
}
|
package/src/lib/audit.js
CHANGED
|
@@ -3,6 +3,11 @@ const STALE_DAYS = 14
|
|
|
3
3
|
const ANCIENT_FALLBACK_DAYS = 168 // ~2× the median B2B cycle
|
|
4
4
|
const ANCIENT_CYCLE_MULTIPLIER = 2
|
|
5
5
|
|
|
6
|
+
/** Jaro-Winkler score at/above which two org names are treated as near-duplicates. */
|
|
7
|
+
export const FUZZY_ORG_THRESHOLD = 0.92
|
|
8
|
+
/** Skip the O(n²) fuzzy pass above this org count to bound the comparison cost. */
|
|
9
|
+
export const FUZZY_ORG_MAX = 2000
|
|
10
|
+
|
|
6
11
|
function openDeals(data) {
|
|
7
12
|
return data.deals.filter((d) => d.status === 'open')
|
|
8
13
|
}
|
|
@@ -159,11 +164,48 @@ export const AUDIT_CHECKS = [
|
|
|
159
164
|
for (const org of data.organizations) {
|
|
160
165
|
const key = normalizeOrgName(org.name)
|
|
161
166
|
if (!key) continue
|
|
162
|
-
|
|
167
|
+
const group = byName.get(key) ?? { ids: [], original: org.name }
|
|
168
|
+
group.ids.push(org.id)
|
|
169
|
+
byName.set(key, group)
|
|
170
|
+
}
|
|
171
|
+
const exact = [...byName.entries()]
|
|
172
|
+
.filter(([, group]) => group.ids.length > 1)
|
|
173
|
+
.map(([name, group]) => ({ kind: 'exact', name, ids: group.ids }))
|
|
174
|
+
|
|
175
|
+
// Normalized names that did NOT collide exactly are fuzzy candidates;
|
|
176
|
+
// exact-duplicate groups are already reported above, so skip them here.
|
|
177
|
+
const singles = [...byName.entries()].filter(
|
|
178
|
+
([, group]) => group.ids.length === 1,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
if (singles.length > FUZZY_ORG_MAX) {
|
|
182
|
+
return [
|
|
183
|
+
...exact,
|
|
184
|
+
{
|
|
185
|
+
kind: 'note',
|
|
186
|
+
note: `Skipped fuzzy near-duplicate scan: ${singles.length} organizations exceed the ${FUZZY_ORG_MAX} cap.`,
|
|
187
|
+
},
|
|
188
|
+
]
|
|
163
189
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
190
|
+
|
|
191
|
+
const fuzzy = []
|
|
192
|
+
for (let i = 0; i < singles.length; i++) {
|
|
193
|
+
for (let j = i + 1; j < singles.length; j++) {
|
|
194
|
+
const [nameA, groupA] = singles[i]
|
|
195
|
+
const [nameB, groupB] = singles[j]
|
|
196
|
+
const score = jaroWinkler(nameA, nameB)
|
|
197
|
+
if (score >= FUZZY_ORG_THRESHOLD) {
|
|
198
|
+
fuzzy.push({
|
|
199
|
+
kind: 'fuzzy',
|
|
200
|
+
// Report original spellings so the orgs are findable in Pipedrive.
|
|
201
|
+
names: [groupA.original, groupB.original],
|
|
202
|
+
ids: [groupA.ids[0], groupB.ids[0]].sort((a, b) => a - b),
|
|
203
|
+
score: Math.round(score * 10000) / 10000,
|
|
204
|
+
})
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return [...exact, ...fuzzy]
|
|
167
209
|
},
|
|
168
210
|
},
|
|
169
211
|
{
|
|
@@ -203,6 +245,62 @@ function normalizeOrgName(name) {
|
|
|
203
245
|
.replace(/[^a-z0-9]/g, '')
|
|
204
246
|
}
|
|
205
247
|
|
|
248
|
+
/**
|
|
249
|
+
* Jaro-Winkler string similarity in [0, 1] (1 = identical). Standard variant:
|
|
250
|
+
* Winkler prefix bonus with scale 0.1 over a max common prefix of 4.
|
|
251
|
+
* Pure and symmetric. Two empty strings score 1; one empty string scores 0.
|
|
252
|
+
* @param {string} a
|
|
253
|
+
* @param {string} b
|
|
254
|
+
* @returns {number}
|
|
255
|
+
*/
|
|
256
|
+
export function jaroWinkler(a, b) {
|
|
257
|
+
if (a === b) return 1
|
|
258
|
+
const lenA = a.length
|
|
259
|
+
const lenB = b.length
|
|
260
|
+
if (lenA === 0 || lenB === 0) return 0
|
|
261
|
+
|
|
262
|
+
const matchWindow = Math.max(0, Math.floor(Math.max(lenA, lenB) / 2) - 1)
|
|
263
|
+
const matchedA = new Array(lenA).fill(false)
|
|
264
|
+
const matchedB = new Array(lenB).fill(false)
|
|
265
|
+
|
|
266
|
+
let matches = 0
|
|
267
|
+
for (let i = 0; i < lenA; i++) {
|
|
268
|
+
const start = Math.max(0, i - matchWindow)
|
|
269
|
+
const end = Math.min(i + matchWindow + 1, lenB)
|
|
270
|
+
for (let j = start; j < end; j++) {
|
|
271
|
+
if (matchedB[j] || a[i] !== b[j]) continue
|
|
272
|
+
matchedA[i] = true
|
|
273
|
+
matchedB[j] = true
|
|
274
|
+
matches++
|
|
275
|
+
break
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (matches === 0) return 0
|
|
279
|
+
|
|
280
|
+
// Count transpositions: matched chars out of order between the two strings.
|
|
281
|
+
let transpositions = 0
|
|
282
|
+
let k = 0
|
|
283
|
+
for (let i = 0; i < lenA; i++) {
|
|
284
|
+
if (!matchedA[i]) continue
|
|
285
|
+
while (!matchedB[k]) k++
|
|
286
|
+
if (a[i] !== b[k]) transpositions++
|
|
287
|
+
k++
|
|
288
|
+
}
|
|
289
|
+
transpositions /= 2
|
|
290
|
+
|
|
291
|
+
const jaro =
|
|
292
|
+
(matches / lenA + matches / lenB + (matches - transpositions) / matches) / 3
|
|
293
|
+
|
|
294
|
+
// Winkler prefix bonus, capped at 4 shared leading characters.
|
|
295
|
+
let prefix = 0
|
|
296
|
+
const maxPrefix = Math.min(4, lenA, lenB)
|
|
297
|
+
for (let i = 0; i < maxPrefix; i++) {
|
|
298
|
+
if (a[i] !== b[i]) break
|
|
299
|
+
prefix++
|
|
300
|
+
}
|
|
301
|
+
return jaro + prefix * 0.1 * (1 - jaro)
|
|
302
|
+
}
|
|
303
|
+
|
|
206
304
|
/**
|
|
207
305
|
* Run all (or a subset of) hygiene checks over pre-fetched account data.
|
|
208
306
|
* @param {{ deals: object[], persons: object[], organizations: object[], activities: object[] }} data
|
|
@@ -214,13 +312,16 @@ export function runChecks(data, { now, only } = {}) {
|
|
|
214
312
|
(check) => {
|
|
215
313
|
const overdueTotal = check.name === 'overdue-activities'
|
|
216
314
|
const items = check.run(data, { now })
|
|
315
|
+
// Informational rows (kind:'note') stay in items but are not findings,
|
|
316
|
+
// so they never contribute to the count consumers branch on.
|
|
317
|
+
const findings = items.filter((i) => i.kind !== 'note')
|
|
217
318
|
return {
|
|
218
319
|
name: check.name,
|
|
219
320
|
severity: check.severity,
|
|
220
321
|
title: check.title,
|
|
221
322
|
count: overdueTotal
|
|
222
|
-
?
|
|
223
|
-
:
|
|
323
|
+
? findings.reduce((sum, i) => sum + i.overdue, 0)
|
|
324
|
+
: findings.length,
|
|
224
325
|
items,
|
|
225
326
|
}
|
|
226
327
|
},
|
package/src/lib/client.js
CHANGED
|
@@ -260,6 +260,35 @@ export function createClient({
|
|
|
260
260
|
return text ? JSON.parse(text) : null
|
|
261
261
|
}
|
|
262
262
|
|
|
263
|
+
/**
|
|
264
|
+
* POST application/x-www-form-urlencoded (v1 form endpoints, e.g.
|
|
265
|
+
* /api/v1/files/remoteLink — JSON is not accepted there).
|
|
266
|
+
* @param {string} path
|
|
267
|
+
* @param {Record<string, unknown>} fields Null/undefined values are omitted.
|
|
268
|
+
*/
|
|
269
|
+
async function postForm(path, fields = {}) {
|
|
270
|
+
const url = lockedUrl(path)
|
|
271
|
+
const params = new URLSearchParams()
|
|
272
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
273
|
+
if (v != null) params.set(k, String(v))
|
|
274
|
+
}
|
|
275
|
+
const res = await fetch(url, {
|
|
276
|
+
method: 'POST',
|
|
277
|
+
headers: {
|
|
278
|
+
...authHeaders(),
|
|
279
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
280
|
+
},
|
|
281
|
+
body: params.toString(),
|
|
282
|
+
signal: AbortSignal.timeout(timeout),
|
|
283
|
+
})
|
|
284
|
+
debug('POST (form) %s → %d', path, res.status)
|
|
285
|
+
const text = await res.text()
|
|
286
|
+
if (!res.ok) {
|
|
287
|
+
throw ApiError.fromResponse(res.status, text, path)
|
|
288
|
+
}
|
|
289
|
+
return text ? JSON.parse(text) : null
|
|
290
|
+
}
|
|
291
|
+
|
|
263
292
|
return {
|
|
264
293
|
get: (path, opts) => request('GET', path, opts),
|
|
265
294
|
post: (path, opts) => request('POST', path, opts),
|
|
@@ -268,6 +297,7 @@ export function createClient({
|
|
|
268
297
|
del: (path, opts) => request('DELETE', path, opts),
|
|
269
298
|
download,
|
|
270
299
|
postMultipart,
|
|
300
|
+
postForm,
|
|
271
301
|
pageV1,
|
|
272
302
|
pageV2,
|
|
273
303
|
}
|
package/src/lib/confirm.js
CHANGED
|
@@ -1,10 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @param {string} message
|
|
3
3
|
* @param {boolean} skipConfirm
|
|
4
|
+
* @param {{ default?: boolean }} [options] Forwarded to the inquirer prompt.
|
|
5
|
+
* Omit to preserve inquirer's native default (true).
|
|
4
6
|
* @returns {Promise<boolean>}
|
|
5
7
|
*/
|
|
6
|
-
export async function confirmAction(message, skipConfirm) {
|
|
8
|
+
export async function confirmAction(message, skipConfirm, options) {
|
|
7
9
|
if (skipConfirm) return true
|
|
8
10
|
const { confirm } = await import('@inquirer/prompts')
|
|
9
|
-
|
|
11
|
+
const promptOptions = { message }
|
|
12
|
+
if (options && Object.prototype.hasOwnProperty.call(options, 'default')) {
|
|
13
|
+
promptOptions.default = options.default
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
return await confirm(promptOptions)
|
|
17
|
+
} catch (err) {
|
|
18
|
+
// Ctrl-C / closed stdin (CI, piping) force-closes the prompt — treat it
|
|
19
|
+
// as a "no" so callers abort cleanly instead of surfacing exit 70.
|
|
20
|
+
if (err?.name === 'ExitPromptError') return false
|
|
21
|
+
throw err
|
|
22
|
+
}
|
|
10
23
|
}
|
package/src/lib/entity-view.js
CHANGED
|
@@ -10,15 +10,21 @@ import { flattenRecord } from './output/record.js'
|
|
|
10
10
|
* for entities without resolvable custom fields (notes, files, webhooks, …)
|
|
11
11
|
*/
|
|
12
12
|
export async function outputRecord(cmd, record, entity) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
13
|
+
const isTable = cmd.resolveFormat() === 'table'
|
|
14
|
+
|
|
15
|
+
// Table always resolves custom fields for readability; non-table formats
|
|
16
|
+
// stay raw for scriptability unless --resolve-fields opts in.
|
|
17
|
+
if (
|
|
18
|
+
(isTable || cmd.flags['resolve-fields']) &&
|
|
19
|
+
entity &&
|
|
20
|
+
record.custom_fields &&
|
|
21
|
+
Object.keys(record.custom_fields).length
|
|
22
|
+
) {
|
|
23
|
+
const defs = await getFields(cmd.apiClient, entity)
|
|
24
|
+
record = makeResolver(defs).resolveCustomFields(record)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (isTable) {
|
|
22
28
|
await cmd.outputResults(flattenRecord(record), {
|
|
23
29
|
field: { header: 'Field' },
|
|
24
30
|
value: { header: 'Value' },
|
package/src/lib/fields.js
CHANGED
|
@@ -105,7 +105,10 @@ export function makeResolver(defs) {
|
|
|
105
105
|
|
|
106
106
|
const resolved = {}
|
|
107
107
|
for (const [key, value] of Object.entries(record.custom_fields)) {
|
|
108
|
-
|
|
108
|
+
let name = this.keyToName(key) ?? key
|
|
109
|
+
// Duplicate field names exist in real accounts — disambiguate with
|
|
110
|
+
// a key fragment rather than silently clobbering the first value.
|
|
111
|
+
if (name in resolved) name = `${name} (${key.slice(0, 8)})`
|
|
109
112
|
let displayValue = value
|
|
110
113
|
if (Array.isArray(value)) {
|
|
111
114
|
displayValue = value.map((v) => this.optionIdToLabel(key, v) ?? v)
|