@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.
@@ -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
- byName.set(key, [...(byName.get(key) ?? []), org.id])
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
- return [...byName.entries()]
165
- .filter(([, ids]) => ids.length > 1)
166
- .map(([name, ids]) => ({ name, ids }))
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
- ? items.reduce((sum, i) => sum + i.overdue, 0)
223
- : items.length,
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
  }
@@ -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
- return confirm({ message })
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
  }
@@ -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
- if (cmd.resolveFormat() === 'table') {
14
- if (
15
- entity &&
16
- record.custom_fields &&
17
- Object.keys(record.custom_fields).length
18
- ) {
19
- const defs = await getFields(cmd.apiClient, entity)
20
- record = makeResolver(defs).resolveCustomFields(record)
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
- const name = this.keyToName(key) ?? key
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)