@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.
@@ -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
+ }