@quotivity/cpq-inventory-core 0.1.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/assets/report/assets/index-CKPi--yh.css +1 -0
- package/assets/report/assets/index-CqUjsqpS.js +9 -0
- package/assets/report/fonts/LexendDeca-VariableFont_wght.ttf +0 -0
- package/assets/report/index.html +14 -0
- package/lib/banner.d.ts +7 -0
- package/lib/banner.js +13 -0
- package/lib/classify/advancedCondition.d.ts +34 -0
- package/lib/classify/advancedCondition.js +112 -0
- package/lib/classify/formula.d.ts +29 -0
- package/lib/classify/formula.js +125 -0
- package/lib/classify/index.d.ts +80 -0
- package/lib/classify/index.js +662 -0
- package/lib/classify/rules.d.ts +31 -0
- package/lib/classify/rules.js +868 -0
- package/lib/config/outbound.d.ts +31 -0
- package/lib/config/outbound.js +35 -0
- package/lib/connection.d.ts +42 -0
- package/lib/connection.js +52 -0
- package/lib/extract/index.d.ts +112 -0
- package/lib/extract/index.js +373 -0
- package/lib/extract/records.d.ts +421 -0
- package/lib/extract/records.js +327 -0
- package/lib/index.d.ts +30 -0
- package/lib/index.js +51 -0
- package/lib/liveness.d.ts +19 -0
- package/lib/liveness.js +34 -0
- package/lib/mapping.d.ts +14 -0
- package/lib/mapping.js +272 -0
- package/lib/report/assemble.d.ts +16 -0
- package/lib/report/assemble.js +499 -0
- package/lib/report/printDoc.d.ts +13 -0
- package/lib/report/printDoc.js +82 -0
- package/lib/report/shareSummary.d.ts +8 -0
- package/lib/report/shareSummary.js +54 -0
- package/lib/report/types.d.ts +146 -0
- package/lib/report/types.js +2 -0
- package/lib/server.d.ts +32 -0
- package/lib/server.js +185 -0
- package/lib/soql.d.ts +50 -0
- package/lib/soql.js +76 -0
- package/lib/summary.d.ts +14 -0
- package/lib/summary.js +143 -0
- package/lib/types.d.ts +22 -0
- package/lib/types.js +85 -0
- package/package.json +35 -0
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
import { MECHANISM_BUCKETS, RULE_ROWS, ruleRow } from '../classify/rules.js';
|
|
2
|
+
import { outboundConfig, SOURCE_URL } from '../config/outbound.js';
|
|
3
|
+
import { MAPPING_ROWS, SHAPE_MEANINGS, VERDICT_MEANINGS } from '../mapping.js';
|
|
4
|
+
import { bucketNote, summaryLines } from '../summary.js';
|
|
5
|
+
import { BUCKETS, bucketDef, PHASES } from '../types.js';
|
|
6
|
+
const ATTENTION = new Set(['Degraded', 'No target']);
|
|
7
|
+
/** Objects that feed each bucket, for unread and bulk-touch reporting. */
|
|
8
|
+
const OBJECT_BUCKETS = {
|
|
9
|
+
Product2: ['catalog', 'price', 'guardrails'],
|
|
10
|
+
Pricebook2: ['catalog'],
|
|
11
|
+
CurrencyType: ['catalog'],
|
|
12
|
+
DatedConversionRate: ['catalog'],
|
|
13
|
+
SBQQ__ProductFeature__c: ['configuration'],
|
|
14
|
+
SBQQ__ProductOption__c: ['configuration'],
|
|
15
|
+
SBQQ__OptionConstraint__c: ['configuration'],
|
|
16
|
+
SBQQ__ConfigurationRule__c: ['configuration'],
|
|
17
|
+
SBQQ__ConfigurationAttribute__c: ['discovery', 'configuration'],
|
|
18
|
+
SBQQ__ProductRule__c: ['configuration', 'guardrails'],
|
|
19
|
+
SBQQ__ErrorCondition__c: ['guardrails'],
|
|
20
|
+
SBQQ__ProductAction__c: ['configuration'],
|
|
21
|
+
SBQQ__QuoteProcess__c: ['discovery'],
|
|
22
|
+
SBQQ__ProcessInput__c: ['discovery'],
|
|
23
|
+
SBQQ__PriceRule__c: ['price', 'discounting', 'discovery', 'guardrails'],
|
|
24
|
+
SBQQ__PriceCondition__c: ['price'],
|
|
25
|
+
SBQQ__PriceAction__c: ['price'],
|
|
26
|
+
SBQQ__SummaryVariable__c: ['price'],
|
|
27
|
+
SBQQ__LookupQuery__c: ['configuration', 'price'],
|
|
28
|
+
SBQQ__BlockPrice__c: ['price'],
|
|
29
|
+
SBQQ__Dimension__c: ['price'],
|
|
30
|
+
SBQQ__DiscountSchedule__c: ['discounting'],
|
|
31
|
+
SBQQ__DiscountTier__c: ['discounting'],
|
|
32
|
+
SBQQ__ContractedPrice__c: ['discounting'],
|
|
33
|
+
ValidationRule: ['guardrails', 'code'],
|
|
34
|
+
sbaa__ApprovalRule__c: ['approvals'],
|
|
35
|
+
sbaa__ApprovalCondition__c: ['approvals'],
|
|
36
|
+
sbaa__ApprovalChain__c: ['approvals'],
|
|
37
|
+
sbaa__Approver__c: ['approvals'],
|
|
38
|
+
sbaa__ApprovalVariable__c: ['approvals'],
|
|
39
|
+
sbaa__TrackedField__c: ['approvals'],
|
|
40
|
+
sbaa__Approval__c: ['approvals'],
|
|
41
|
+
ProcessDefinition: ['approvals'],
|
|
42
|
+
ProcessInstance: ['approvals'],
|
|
43
|
+
SBQQ__QuoteTemplate__c: ['output'],
|
|
44
|
+
SBQQ__TemplateSection__c: ['output'],
|
|
45
|
+
SBQQ__TemplateContent__c: ['output'],
|
|
46
|
+
SBQQ__LineColumn__c: ['output'],
|
|
47
|
+
SBQQ__QuoteTerm__c: ['output'],
|
|
48
|
+
SBQQ__TermCondition__c: ['output'],
|
|
49
|
+
SBQQ__QuoteLineGroup__c: ['output'],
|
|
50
|
+
Contract: ['lifecycle'],
|
|
51
|
+
SBQQ__Subscription__c: ['lifecycle'],
|
|
52
|
+
SBQQ__Quote__c: ['lifecycle'],
|
|
53
|
+
SBQQ__CustomScript__c: ['code'],
|
|
54
|
+
SBQQ__CustomAction__c: ['code'],
|
|
55
|
+
ApexTrigger: ['code'],
|
|
56
|
+
WebLink: ['code'],
|
|
57
|
+
Layout: ['code'],
|
|
58
|
+
FlexiPage: ['code'],
|
|
59
|
+
FieldDefinition: ['code'],
|
|
60
|
+
};
|
|
61
|
+
export function assembleReport(ex, cl, opts) {
|
|
62
|
+
const now = opts.now ?? new Date();
|
|
63
|
+
const key = (l) => `${l.bucket}|${l.rowId}`;
|
|
64
|
+
const tallies = new Map();
|
|
65
|
+
const get = (l) => {
|
|
66
|
+
const k = key(l);
|
|
67
|
+
let t = tallies.get(k);
|
|
68
|
+
if (!t) {
|
|
69
|
+
t = { count: 0, alive: 0, names: [], details: [], primaryBuckets: new Map(), primary: false };
|
|
70
|
+
tallies.set(k, t);
|
|
71
|
+
}
|
|
72
|
+
return t;
|
|
73
|
+
};
|
|
74
|
+
const primaryCount = new Map();
|
|
75
|
+
const primaryAlive = new Map();
|
|
76
|
+
const primaryAttention = new Map();
|
|
77
|
+
const primaryReview = new Map();
|
|
78
|
+
const bump = (m, b, by = 1) => m.set(b, (m.get(b) ?? 0) + by);
|
|
79
|
+
const objectBuckets = new Map();
|
|
80
|
+
for (const rec of cl.records) {
|
|
81
|
+
const p = get(rec.primary);
|
|
82
|
+
p.count += 1;
|
|
83
|
+
if (rec.alive)
|
|
84
|
+
p.alive += 1;
|
|
85
|
+
p.names.push(rec.name);
|
|
86
|
+
p.details.push(...rec.details);
|
|
87
|
+
p.primaryBuckets.set(rec.primary.bucket, (p.primaryBuckets.get(rec.primary.bucket) ?? 0) + 1);
|
|
88
|
+
p.primary = true;
|
|
89
|
+
bump(primaryCount, rec.primary.bucket);
|
|
90
|
+
if (rec.alive)
|
|
91
|
+
bump(primaryAlive, rec.primary.bucket);
|
|
92
|
+
const verdict = ruleRow(rec.primary.rowId).verdict;
|
|
93
|
+
if (ATTENTION.has(verdict))
|
|
94
|
+
bump(primaryAttention, rec.primary.bucket);
|
|
95
|
+
if (verdict === 'Further review')
|
|
96
|
+
bump(primaryReview, rec.primary.bucket);
|
|
97
|
+
const set = objectBuckets.get(rec.object) ?? new Set();
|
|
98
|
+
set.add(rec.primary.bucket);
|
|
99
|
+
objectBuckets.set(rec.object, set);
|
|
100
|
+
for (const s of rec.secondary) {
|
|
101
|
+
const t = get(s);
|
|
102
|
+
t.count += 1;
|
|
103
|
+
if (rec.alive)
|
|
104
|
+
t.alive += 1;
|
|
105
|
+
t.names.push(rec.name);
|
|
106
|
+
t.primaryBuckets.set(rec.primary.bucket, (t.primaryBuckets.get(rec.primary.bucket) ?? 0) + 1);
|
|
107
|
+
set.add(s.bucket);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
for (const t of cl.tallies) {
|
|
111
|
+
const r = get({ rowId: t.rowId, bucket: t.bucket });
|
|
112
|
+
r.count += t.count;
|
|
113
|
+
r.alive += t.alive;
|
|
114
|
+
r.details.push(...(t.details ?? []));
|
|
115
|
+
r.primary = true;
|
|
116
|
+
r.primaryBuckets.set(t.bucket, (r.primaryBuckets.get(t.bucket) ?? 0) + t.count);
|
|
117
|
+
bump(primaryCount, t.bucket, t.count);
|
|
118
|
+
bump(primaryAlive, t.bucket, t.alive);
|
|
119
|
+
const verdict = ruleRow(t.rowId).verdict;
|
|
120
|
+
if (ATTENTION.has(verdict))
|
|
121
|
+
bump(primaryAttention, t.bucket, t.count);
|
|
122
|
+
if (verdict === 'Further review')
|
|
123
|
+
bump(primaryReview, t.bucket, t.count);
|
|
124
|
+
}
|
|
125
|
+
/** Records carrying the row, counted once each even when cross-listed, plus count-only tallies. */
|
|
126
|
+
const rowCountAll = (rowId) => {
|
|
127
|
+
let total = 0;
|
|
128
|
+
for (const rec of cl.records)
|
|
129
|
+
if (rec.primary.rowId === rowId || rec.secondary.some((s) => s.rowId === rowId))
|
|
130
|
+
total += 1;
|
|
131
|
+
for (const t of cl.tallies)
|
|
132
|
+
if (t.rowId === rowId)
|
|
133
|
+
total += t.count;
|
|
134
|
+
return total;
|
|
135
|
+
};
|
|
136
|
+
const buckets = [];
|
|
137
|
+
for (const def of BUCKETS) {
|
|
138
|
+
const rows = [];
|
|
139
|
+
for (const rule of RULE_ROWS) {
|
|
140
|
+
const t = tallies.get(`${def.id}|${rule.id}`);
|
|
141
|
+
const staticHere = rule.bucket === def.id;
|
|
142
|
+
if (!t && !staticHere)
|
|
143
|
+
continue;
|
|
144
|
+
const count = t?.count ?? 0;
|
|
145
|
+
let crossListed;
|
|
146
|
+
if (t && !t.primary) {
|
|
147
|
+
let best = null;
|
|
148
|
+
let bestN = -1;
|
|
149
|
+
for (const [b, n] of t.primaryBuckets)
|
|
150
|
+
if (n > bestN)
|
|
151
|
+
[best, bestN] = [b, n];
|
|
152
|
+
if (best && best !== def.id)
|
|
153
|
+
crossListed = { countedIn: best, countedInName: bucketDef(best).name };
|
|
154
|
+
else if (best)
|
|
155
|
+
crossListed = {
|
|
156
|
+
countedIn: best,
|
|
157
|
+
countedInName: `its own row in ${bucketDef(best).name}`,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
else if (t && rule.crossBucket === def.id && rule.bucket !== def.id) {
|
|
161
|
+
crossListed = {
|
|
162
|
+
countedIn: rule.bucket,
|
|
163
|
+
countedInName: bucketDef(rule.bucket).name,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
rows.push({
|
|
167
|
+
rowId: rule.id,
|
|
168
|
+
label: rule.label,
|
|
169
|
+
count,
|
|
170
|
+
unit: rule.unit,
|
|
171
|
+
alive: t?.alive ?? 0,
|
|
172
|
+
lands: rule.lands,
|
|
173
|
+
verdict: rule.verdict,
|
|
174
|
+
shape: rule.shape,
|
|
175
|
+
review: rule.review,
|
|
176
|
+
crossListed,
|
|
177
|
+
names: (t?.names ?? []).slice(0, 200),
|
|
178
|
+
details: [...new Set(t?.details ?? [])].slice(0, 50),
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
const mechanisms = MECHANISM_BUCKETS.has(def.id);
|
|
182
|
+
const nonEmpty = rows.filter((r) => r.count > 0);
|
|
183
|
+
const objects = [...objectBuckets].filter(([, b]) => b.has(def.id)).map(([o]) => o);
|
|
184
|
+
const partial = cl.objects.some((o) => o.partial && (objects.includes(o.object) || OBJECT_BUCKETS[o.object]?.includes(def.id)));
|
|
185
|
+
const beyondCap = cl.objects
|
|
186
|
+
.filter((o) => o.partial && objects.includes(o.object))
|
|
187
|
+
.reduce((a, o) => a + Math.max(0, o.count - o.retrieved), 0);
|
|
188
|
+
const aliveUnreliable = cl.objects
|
|
189
|
+
.filter((o) => o.bulkTouch && (objects.includes(o.object) || OBJECT_BUCKETS[o.object]?.includes(def.id)))
|
|
190
|
+
.map((o) => ({ object: o.object, date: o.bulkTouch?.date ?? '' }));
|
|
191
|
+
const unread = ex.unread
|
|
192
|
+
.filter((u) => OBJECT_BUCKETS[u.object]?.includes(def.id))
|
|
193
|
+
.map((u) => u.object);
|
|
194
|
+
let exists;
|
|
195
|
+
let alive;
|
|
196
|
+
let needsAttention;
|
|
197
|
+
let review;
|
|
198
|
+
if (mechanisms) {
|
|
199
|
+
exists = nonEmpty.length;
|
|
200
|
+
alive = nonEmpty.filter((r) => r.alive > 0).length;
|
|
201
|
+
needsAttention = nonEmpty.filter((r) => ATTENTION.has(r.verdict)).length;
|
|
202
|
+
const rv = nonEmpty.filter((r) => r.verdict === 'Further review').length;
|
|
203
|
+
review = rv > 0 ? rv : undefined;
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
exists = (primaryCount.get(def.id) ?? 0) + beyondCap;
|
|
207
|
+
alive = def.id === 'code' ? null : (primaryAlive.get(def.id) ?? 0);
|
|
208
|
+
needsAttention = primaryAttention.get(def.id) ?? 0;
|
|
209
|
+
const rv = primaryReview.get(def.id) ?? 0;
|
|
210
|
+
review = rv > 0 ? rv : undefined;
|
|
211
|
+
}
|
|
212
|
+
const input = {
|
|
213
|
+
ex,
|
|
214
|
+
cl,
|
|
215
|
+
rowCount: rowCountAll,
|
|
216
|
+
bucketAlive: (id) => (id === 'code' ? null : (primaryAlive.get(id) ?? 0)),
|
|
217
|
+
mechanisms: nonEmpty.length,
|
|
218
|
+
};
|
|
219
|
+
buckets.push({
|
|
220
|
+
id: def.id,
|
|
221
|
+
name: def.name,
|
|
222
|
+
question: def.question,
|
|
223
|
+
phase: def.phase,
|
|
224
|
+
exists,
|
|
225
|
+
alive,
|
|
226
|
+
aliveUnreliable,
|
|
227
|
+
needsAttention,
|
|
228
|
+
review,
|
|
229
|
+
countsMechanisms: mechanisms,
|
|
230
|
+
summary: summaryLines(def.id, input),
|
|
231
|
+
rows,
|
|
232
|
+
note: bucketNote(def.id, input),
|
|
233
|
+
partial,
|
|
234
|
+
unread,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
// ── Stage 2 ─────────────────────────────────────────────────────────────
|
|
238
|
+
const mappingCounts = new Map();
|
|
239
|
+
for (const rule of RULE_ROWS)
|
|
240
|
+
mappingCounts.set(rule.mapping, (mappingCounts.get(rule.mapping) ?? 0) + rowCountAll(rule.id));
|
|
241
|
+
const mapping = MAPPING_ROWS.map((m) => ({
|
|
242
|
+
id: m.id,
|
|
243
|
+
bucket: m.bucket,
|
|
244
|
+
bucketName: bucketDef(m.bucket).name,
|
|
245
|
+
from: m.from,
|
|
246
|
+
to: m.to,
|
|
247
|
+
verdict: m.verdict,
|
|
248
|
+
shape: m.shape,
|
|
249
|
+
count: mappingCounts.get(m.id) ?? 0,
|
|
250
|
+
}));
|
|
251
|
+
const verdicted = mapping.filter((m) => m.verdict !== 'Further review').length;
|
|
252
|
+
const verdictScale = ['Clear path', 'Degraded', 'No target'].map((label) => {
|
|
253
|
+
const count = mapping.filter((m) => m.verdict === label).length;
|
|
254
|
+
return {
|
|
255
|
+
label,
|
|
256
|
+
meaning: VERDICT_MEANINGS[label],
|
|
257
|
+
count,
|
|
258
|
+
pct: verdicted ? Math.round((count / verdicted) * 100) : 0,
|
|
259
|
+
};
|
|
260
|
+
});
|
|
261
|
+
const shapeLegend = ['1:1', 'Fan-out', 'Rebuild'].map((label) => ({
|
|
262
|
+
label,
|
|
263
|
+
meaning: SHAPE_MEANINGS[label],
|
|
264
|
+
count: mapping.filter((m) => m.shape === label).length,
|
|
265
|
+
}));
|
|
266
|
+
const reviewCount = mapping.filter((m) => m.verdict === 'Further review').length;
|
|
267
|
+
const namesFor = (rowId) => {
|
|
268
|
+
const out = [];
|
|
269
|
+
for (const [k, t] of tallies)
|
|
270
|
+
if (k.endsWith(`|${rowId}`) && t.primary)
|
|
271
|
+
out.push(...t.names);
|
|
272
|
+
return [...new Set(out)];
|
|
273
|
+
};
|
|
274
|
+
const review = [];
|
|
275
|
+
const lookupProduct = rowCountAll('cfg.lookupProductRule');
|
|
276
|
+
if (lookupProduct)
|
|
277
|
+
review.push({
|
|
278
|
+
reason: 'Unread',
|
|
279
|
+
subject: `Lookup queries on a product rule — ${lookupProduct}`,
|
|
280
|
+
count: lookupProduct,
|
|
281
|
+
question: 'How many product properties does your lookup table key on?',
|
|
282
|
+
outA: 'Keys on one property: this is a pricing table and a direct port.',
|
|
283
|
+
outB: 'Keys on three: it becomes product variants, or an expanded catalogue.',
|
|
284
|
+
names: namesFor('cfg.lookupProductRule'),
|
|
285
|
+
});
|
|
286
|
+
const lookupPrice = rowCountAll('price.lookupPriceRule');
|
|
287
|
+
if (lookupPrice)
|
|
288
|
+
review.push({
|
|
289
|
+
reason: 'Unread',
|
|
290
|
+
subject: `Lookup queries on a price rule — ${lookupPrice}`,
|
|
291
|
+
count: lookupPrice,
|
|
292
|
+
question: 'Are the lookup inputs properties of the product, or values from another object?',
|
|
293
|
+
outA: 'Product properties: a pricing table carries it.',
|
|
294
|
+
outB: 'Another object: custom object values cannot feed a pricing formula, so the matrix is re-expressed as variants or products.',
|
|
295
|
+
names: namesFor('price.lookupPriceRule'),
|
|
296
|
+
});
|
|
297
|
+
const codeCount = rowCountAll('code.qcp') +
|
|
298
|
+
rowCountAll('code.triggers') +
|
|
299
|
+
rowCountAll('code.customActions') +
|
|
300
|
+
rowCountAll('code.ui');
|
|
301
|
+
if (codeCount)
|
|
302
|
+
review.push({
|
|
303
|
+
reason: 'Unread',
|
|
304
|
+
subject: `Quote Calculator Plugins, triggers and UI — ${codeCount}`,
|
|
305
|
+
count: codeCount,
|
|
306
|
+
question: 'What does each script actually do at its calculator hook?',
|
|
307
|
+
outA: 'Pricing maths: calculated pricing absorbs it declaratively.',
|
|
308
|
+
outB: 'An external call or a cross-system lookup: it needs a redesign, and the call decides which.',
|
|
309
|
+
names: [
|
|
310
|
+
...namesFor('code.qcp'),
|
|
311
|
+
...namesFor('code.triggers'),
|
|
312
|
+
...namesFor('code.customActions'),
|
|
313
|
+
...namesFor('code.ui'),
|
|
314
|
+
],
|
|
315
|
+
});
|
|
316
|
+
const prerequisites = buildPrerequisites(ex);
|
|
317
|
+
const seeded = Object.entries(ex.seeded).map(([object, count]) => ({ object, count }));
|
|
318
|
+
const primaryTotal = cl.records.length + cl.residue.length;
|
|
319
|
+
const residuePct = primaryTotal ? (cl.residue.length / primaryTotal) * 100 : 0;
|
|
320
|
+
const dateStamp = now.toISOString().slice(0, 10);
|
|
321
|
+
const orgSlug = opts.org.name
|
|
322
|
+
.toLowerCase()
|
|
323
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
324
|
+
.replace(/^-|-$/g, '') || 'org';
|
|
325
|
+
const facts = cl.facts;
|
|
326
|
+
const dormantBundles = ex.productFeature.records.filter((r) => !r.LastModifiedDate || r.LastModifiedDate < ex.since).length;
|
|
327
|
+
const neverFired = Math.max(0, ex.approvalRule.count - facts.approvalRulesFired);
|
|
328
|
+
const windowLabel = `${ex.windowMonths} months`;
|
|
329
|
+
const highlights = `In this org: ${dormantBundles} bundle option${dormantBundles === 1 ? '' : 's'} not configured since ${ex.since.slice(0, 4)}, ${neverFired} approval rule${neverFired === 1 ? '' : 's'} that ${neverFired === 1 ? 'has' : 'have'} not fired in the last 12 months, ${facts.productsNeverQuoted} product${facts.productsNeverQuoted === 1 ? '' : 's'} never quoted inside the ${windowLabel} window, and ${ex.customScript.count} Quote Calculator Plugin${ex.customScript.count === 1 ? '' : 's'}.`;
|
|
330
|
+
const years = new Map();
|
|
331
|
+
for (const q of ex.quotesByYear) {
|
|
332
|
+
const y = years.get(q.year) ?? { total: 0, amendRenew: 0 };
|
|
333
|
+
y.total += q.count;
|
|
334
|
+
if (/amendment|renewal/i.test(q.type))
|
|
335
|
+
y.amendRenew += q.count;
|
|
336
|
+
years.set(q.year, y);
|
|
337
|
+
}
|
|
338
|
+
return {
|
|
339
|
+
version: opts.version,
|
|
340
|
+
runId: opts.runId,
|
|
341
|
+
generatedAt: now.toISOString(),
|
|
342
|
+
org: opts.org,
|
|
343
|
+
window: { months: ex.windowMonths, since: ex.since, label: windowLabel },
|
|
344
|
+
quoteDateField: ex.quoteDateField,
|
|
345
|
+
phases: PHASES.map((p) => ({ id: p.id, name: p.name, note: p.note, buckets: [...p.buckets] })),
|
|
346
|
+
buckets,
|
|
347
|
+
verdictScale,
|
|
348
|
+
shapeLegend,
|
|
349
|
+
reviewCount,
|
|
350
|
+
mapping,
|
|
351
|
+
review,
|
|
352
|
+
notes: buildNotes(ex, cl, rowCountAll),
|
|
353
|
+
prerequisites,
|
|
354
|
+
seeded,
|
|
355
|
+
unread: ex.unread,
|
|
356
|
+
unscanned: 'Apex classes and Flows were not scanned. Neither can be attributed to an object reliably, so logic may exist there that this report does not see.',
|
|
357
|
+
residue: { count: cl.residue.length, pct: Math.round(residuePct * 10) / 10 },
|
|
358
|
+
highlights,
|
|
359
|
+
volume: {
|
|
360
|
+
quotesByYear: [...years].sort((a, b) => a[0] - b[0]).map(([year, v]) => ({ year, ...v })),
|
|
361
|
+
quotesInWindow: ex.quotesInWindow,
|
|
362
|
+
distinctProductsQuoted: ex.distinctProductsQuoted,
|
|
363
|
+
},
|
|
364
|
+
outbound: outboundConfig(),
|
|
365
|
+
fileName: `quotivity-cpq-inventory-${orgSlug}-${dateStamp}`,
|
|
366
|
+
sourceUrl: SOURCE_URL,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
const bareName = (f) => (f ?? '').split('.').pop() ?? '';
|
|
370
|
+
function buildPrerequisites(ex) {
|
|
371
|
+
const custom = new Map();
|
|
372
|
+
for (const f of ex.quoteFields.records)
|
|
373
|
+
if (!f.NamespacePrefix && f.QualifiedApiName.endsWith('__c'))
|
|
374
|
+
custom.set(f.QualifiedApiName, 'SBQQ__Quote__c');
|
|
375
|
+
for (const f of ex.quoteLineFields.records)
|
|
376
|
+
if (!f.NamespacePrefix && f.QualifiedApiName.endsWith('__c'))
|
|
377
|
+
custom.set(f.QualifiedApiName, 'SBQQ__QuoteLine__c');
|
|
378
|
+
if (!custom.size)
|
|
379
|
+
return [];
|
|
380
|
+
const refs = new Map();
|
|
381
|
+
const ref = (field, by) => {
|
|
382
|
+
const name = bareName(field);
|
|
383
|
+
if (!custom.has(name))
|
|
384
|
+
return;
|
|
385
|
+
const s = refs.get(name) ?? new Set();
|
|
386
|
+
s.add(by);
|
|
387
|
+
refs.set(name, s);
|
|
388
|
+
};
|
|
389
|
+
for (const c of ex.priceCondition.records)
|
|
390
|
+
ref(c.SBQQ__Field__c, 'price rule conditions');
|
|
391
|
+
for (const a of ex.priceAction.records) {
|
|
392
|
+
ref(a.SBQQ__Field__c, 'price rule actions');
|
|
393
|
+
for (const f of custom.keys())
|
|
394
|
+
if (a.SBQQ__Formula__c?.includes(f))
|
|
395
|
+
ref(f, 'price formulas');
|
|
396
|
+
}
|
|
397
|
+
for (const c of ex.errorCondition.records)
|
|
398
|
+
ref(c.SBQQ__TestedField__c, 'validation rules');
|
|
399
|
+
for (const s of ex.summaryVariable.records) {
|
|
400
|
+
ref(s.SBQQ__AggregateField__c, 'summary variables');
|
|
401
|
+
ref(s.SBQQ__FilterField__c, 'summary variables');
|
|
402
|
+
ref(s.SBQQ__ConstraintField__c, 'summary variables');
|
|
403
|
+
}
|
|
404
|
+
for (const c of ex.approvalCondition.records)
|
|
405
|
+
ref(c.sbaa__TestedField__c, 'approval rules');
|
|
406
|
+
for (const t of ex.termCondition.records)
|
|
407
|
+
ref(t.SBQQ__Field__c, 'term conditions');
|
|
408
|
+
for (const l of ex.lineColumn.records)
|
|
409
|
+
ref(l.SBQQ__FieldName__c, 'template line columns');
|
|
410
|
+
for (const a of ex.configurationAttribute.records)
|
|
411
|
+
ref(a.SBQQ__TargetField__c, 'configuration attributes');
|
|
412
|
+
const code = new Map();
|
|
413
|
+
const codeRef = (body, by) => {
|
|
414
|
+
if (!body)
|
|
415
|
+
return;
|
|
416
|
+
for (const f of custom.keys()) {
|
|
417
|
+
if (body.includes(f)) {
|
|
418
|
+
const s = code.get(f) ?? new Set();
|
|
419
|
+
s.add(by);
|
|
420
|
+
code.set(f, s);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
for (const s of ex.customScript.records)
|
|
425
|
+
codeRef(s.SBQQ__Code__c, `QCP ${s.Name}`);
|
|
426
|
+
for (const t of ex.apexTrigger.records)
|
|
427
|
+
codeRef(t.Body, `trigger ${t.Name}`);
|
|
428
|
+
const out = [];
|
|
429
|
+
for (const [field, object] of custom) {
|
|
430
|
+
const by = refs.get(field);
|
|
431
|
+
const byCode = code.get(field);
|
|
432
|
+
if (!by && !byCode)
|
|
433
|
+
continue;
|
|
434
|
+
const kind = object === 'SBQQ__Quote__c' ? 'Quote property' : 'Line item property';
|
|
435
|
+
const parts = [];
|
|
436
|
+
if (by)
|
|
437
|
+
parts.push(`read by ${[...by].join(', ')}`);
|
|
438
|
+
if (byCode)
|
|
439
|
+
parts.push(`written by ${[...byCode].join(', ')} — no declarative source until the code question is settled`);
|
|
440
|
+
out.push({ field, object, need: `${kind} — ${parts.join('; ')}`, review: !!byCode });
|
|
441
|
+
}
|
|
442
|
+
return out.sort((a, b) => Number(a.review) - Number(b.review) || a.field.localeCompare(b.field));
|
|
443
|
+
}
|
|
444
|
+
function buildNotes(ex, cl, rowCount) {
|
|
445
|
+
const f = cl.facts;
|
|
446
|
+
const notes = [
|
|
447
|
+
{
|
|
448
|
+
head: 'Clear path is not a claim that it is easy',
|
|
449
|
+
body: 'It says the behaviour survives and the route is known. A quote template rebuilt from scratch is a clear path — the finished document is reproducible and every part of it has a destination. Shape is where the work shows: Fan-out means one construct becomes several records, Rebuild means it is authored fresh rather than imported.',
|
|
450
|
+
},
|
|
451
|
+
{
|
|
452
|
+
head: 'Ramp pricing has hard edges, and a route around them',
|
|
453
|
+
body: 'A ramp needs at least two periods, applies only to recurring lines, and cannot be applied to a bundle header or an option product. Stepped periods on a bundled product are still buildable with Update Bundle Members rules — what is lost is the native grouping, period badges and MRR handling. Quantity is also locked across ramp periods, which is the most common reason a segmented subscription lands on Degraded.',
|
|
454
|
+
},
|
|
455
|
+
{
|
|
456
|
+
head: 'Dated conversion rates: quoting carries, authoring does not',
|
|
457
|
+
body: `HubSpot holds one current rate per currency, set manually or updated on a schedule from Open Exchange Rates. A deal's rate is stamped at close and history stays viewable, so point-in-time quoting carries. What does not carry is authoring a rate against a named past or future period. ${rowCount('cat.dcrTable')
|
|
458
|
+
? `${rowCount('cat.dcrTable')} of your currencies maintain more than one rate period, which is the practice that does not reproduce.`
|
|
459
|
+
: 'Every currency here holds one rate period, which a manual entry reproduces.'}`,
|
|
460
|
+
},
|
|
461
|
+
{
|
|
462
|
+
head: 'The formula limits are specific',
|
|
463
|
+
body: 'Calculated pricing supports IF, CASE, AND, OR, NOT, ISBLANK, BLANKVALUE, MIN and MAX — and AND / OR take two to five arguments each. A price action that ANDs six conditions is over the limit even though every function it uses is supported. The report prints the specific limit each formula breaches.',
|
|
464
|
+
},
|
|
465
|
+
{
|
|
466
|
+
head: 'Contracted pricing states its cardinality',
|
|
467
|
+
body: `It lands as a price book assigned to a company, which also locks the quote so reps cannot switch away from negotiated pricing. ${ex.contractedPrice.count
|
|
468
|
+
? `Your ${ex.contractedPrice.count} contracted prices across ${f.contractedPriceAccounts} accounts become ${f.contractedPriceAccounts} price books and ${ex.contractedPrice.count} entries, expressed as import file rows.`
|
|
469
|
+
: 'This org holds no contracted prices.'}`,
|
|
470
|
+
},
|
|
471
|
+
{
|
|
472
|
+
head: 'Discount tiers are half-open',
|
|
473
|
+
body: 'CPQ validates that each tier lower bound equals the previous upper bound, so a schedule reads 1–5, 5–10, 10–25. Reading those as inclusive ranges shifts every boundary by one unit and misprices the tier edges, so the convention is preserved on import.',
|
|
474
|
+
},
|
|
475
|
+
{
|
|
476
|
+
head: 'Package-owned records are excluded everywhere',
|
|
477
|
+
body: `CPQ ships its own metadata and configuration records into every org. Counted naively they become your customisation. Namespaced components are filtered on namespace; seeded configuration records are identified by an install signature. ${Object.keys(ex.seeded).length
|
|
478
|
+
? `In this org: ${Object.entries(ex.seeded)
|
|
479
|
+
.map(([o, c]) => `${c} ${o}`)
|
|
480
|
+
.join(', ')} shipped with CPQ and were not counted.`
|
|
481
|
+
: 'No seeded configuration records were detected in this org.'}`,
|
|
482
|
+
},
|
|
483
|
+
{
|
|
484
|
+
head: 'Apex classes and Flows were not scanned',
|
|
485
|
+
body: 'Neither can be attributed to a CPQ object reliably — matching strings in a class body over-matches on incidental references and under-matches on dynamic SOQL. Logic may exist there that this report does not see.',
|
|
486
|
+
},
|
|
487
|
+
];
|
|
488
|
+
if (f.ruleConditionProblems.length) {
|
|
489
|
+
notes.push({
|
|
490
|
+
head: 'Custom condition logic that does not parse',
|
|
491
|
+
body: `${f.ruleConditionProblems.length} rule${f.ruleConditionProblems.length === 1 ? '' : 's'} carry a Custom conditions-met expression the tool could not resolve against the conditions on the rule: ${f.ruleConditionProblems
|
|
492
|
+
.slice(0, 5)
|
|
493
|
+
.map((p) => `${p.rule} (${p.problem})`)
|
|
494
|
+
.join('; ')}. Those rules are counted under their type but their logic needs a look.`,
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
return notes;
|
|
498
|
+
}
|
|
499
|
+
//# sourceMappingURL=assemble.js.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ReportData } from './types.js';
|
|
2
|
+
/** Compiled report app, produced by `packages/report` at publish time. */
|
|
3
|
+
export declare const ASSETS_DIR: string;
|
|
4
|
+
export declare const MIME: Record<string, string>;
|
|
5
|
+
export declare function assetsAvailable(dir?: string): boolean;
|
|
6
|
+
/** Inlines url(...) references inside CSS as data URIs so fonts and images travel with the page. */
|
|
7
|
+
export declare function inlineCssUrls(css: string, dir: string, root?: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* Assembles the single self-contained print document: the compiled app with every script, style,
|
|
10
|
+
* font and image inlined, the report data embedded, and the title set so the saved PDF names itself.
|
|
11
|
+
*/
|
|
12
|
+
export declare function buildPrintDocument(report: ReportData, dir?: string): string;
|
|
13
|
+
//# sourceMappingURL=printDoc.d.ts.map
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, extname, join, resolve } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
5
|
+
/** Compiled report app, produced by `packages/report` at publish time. */
|
|
6
|
+
export const ASSETS_DIR = resolve(here, '..', '..', 'assets', 'report');
|
|
7
|
+
export const MIME = {
|
|
8
|
+
'.html': 'text/html; charset=utf-8',
|
|
9
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
10
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
11
|
+
'.css': 'text/css; charset=utf-8',
|
|
12
|
+
'.json': 'application/json; charset=utf-8',
|
|
13
|
+
'.png': 'image/png',
|
|
14
|
+
'.svg': 'image/svg+xml',
|
|
15
|
+
'.ttf': 'font/ttf',
|
|
16
|
+
'.woff': 'font/woff',
|
|
17
|
+
'.woff2': 'font/woff2',
|
|
18
|
+
'.ico': 'image/x-icon',
|
|
19
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
20
|
+
};
|
|
21
|
+
export function assetsAvailable(dir = ASSETS_DIR) {
|
|
22
|
+
return existsSync(join(dir, 'index.html'));
|
|
23
|
+
}
|
|
24
|
+
function dataUri(path) {
|
|
25
|
+
const mime = MIME[extname(path).toLowerCase()] ?? 'application/octet-stream';
|
|
26
|
+
return `data:${mime.split(';')[0]};base64,${readFileSync(path).toString('base64')}`;
|
|
27
|
+
}
|
|
28
|
+
function resolveAsset(dir, ref, root = dir) {
|
|
29
|
+
const bare = ref.split(/[?#]/)[0] ?? '';
|
|
30
|
+
// Root-absolute references (Vite's public assets) resolve against the assets root; relative ones
|
|
31
|
+
// against the referencing file's folder.
|
|
32
|
+
const path = bare.startsWith('/')
|
|
33
|
+
? resolve(root, bare.slice(1))
|
|
34
|
+
: resolve(dir, bare.replace(/^\.\//, ''));
|
|
35
|
+
if (!path.startsWith(root) || !existsSync(path))
|
|
36
|
+
return null;
|
|
37
|
+
return path;
|
|
38
|
+
}
|
|
39
|
+
/** Inlines url(...) references inside CSS as data URIs so fonts and images travel with the page. */
|
|
40
|
+
export function inlineCssUrls(css, dir, root = dir) {
|
|
41
|
+
return css.replace(/url\((['"]?)([^'")]+)\1\)/g, (m, q, ref) => {
|
|
42
|
+
if (/^(data:|https?:|\/\/)/.test(ref))
|
|
43
|
+
return m;
|
|
44
|
+
const path = resolveAsset(dir, ref, root);
|
|
45
|
+
return path ? `url(${q}${dataUri(path)}${q})` : m;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
const escapeForScript = (json) => json.replace(/<\/script/gi, '<\\/script').replace(/<!--/g, '<\\!--');
|
|
49
|
+
/**
|
|
50
|
+
* Assembles the single self-contained print document: the compiled app with every script, style,
|
|
51
|
+
* font and image inlined, the report data embedded, and the title set so the saved PDF names itself.
|
|
52
|
+
*/
|
|
53
|
+
export function buildPrintDocument(report, dir = ASSETS_DIR) {
|
|
54
|
+
let html = readFileSync(join(dir, 'index.html'), 'utf8');
|
|
55
|
+
html = html.replace(/<link\b[^>]*rel=["']stylesheet["'][^>]*>/gi, (tag) => {
|
|
56
|
+
const href = /href=["']([^"']+)["']/i.exec(tag)?.[1];
|
|
57
|
+
const path = href ? resolveAsset(dir, href) : null;
|
|
58
|
+
if (!path)
|
|
59
|
+
return tag;
|
|
60
|
+
return `<style>${inlineCssUrls(readFileSync(path, 'utf8'), dirname(path), dir)}</style>`;
|
|
61
|
+
});
|
|
62
|
+
html = html.replace(/<link\b[^>]*rel=["'](?:icon|modulepreload)["'][^>]*>/gi, '');
|
|
63
|
+
html = html.replace(/<script\b([^>]*)\bsrc=["']([^"']+)["']([^>]*)><\/script>/gi, (tag, pre, src, post) => {
|
|
64
|
+
const path = resolveAsset(dir, src);
|
|
65
|
+
if (!path)
|
|
66
|
+
return tag;
|
|
67
|
+
const attrs = `${pre} ${post}`.replace(/\s*crossorigin\s*/i, ' ').trim();
|
|
68
|
+
return `<script ${attrs}>${escapeForScript(readFileSync(path, 'utf8'))}</script>`;
|
|
69
|
+
});
|
|
70
|
+
html = html.replace(/<img\b([^>]*)\bsrc=["']([^"']+)["']/gi, (tag, pre, src) => {
|
|
71
|
+
const path = resolveAsset(dir, src);
|
|
72
|
+
return path ? `<img${pre} src="${dataUri(path)}"` : tag;
|
|
73
|
+
});
|
|
74
|
+
const data = `<script>window.__CPQ_REPORT__=${escapeForScript(JSON.stringify(report))};window.__CPQ_PRINT__=true;</script>`;
|
|
75
|
+
const title = `<title>${report.fileName}</title>`;
|
|
76
|
+
html = html.replace(/<title>[^<]*<\/title>/i, title);
|
|
77
|
+
if (!/<title>/i.test(html))
|
|
78
|
+
html = html.replace(/<head>/i, `<head>${title}`);
|
|
79
|
+
html = html.replace(/<head>/i, `<head>${data}`);
|
|
80
|
+
return html;
|
|
81
|
+
}
|
|
82
|
+
//# sourceMappingURL=printDoc.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ReportData } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* The compact summary that travels with the meeting-request form: the ten buckets, the mapping,
|
|
4
|
+
* the verdicts and the names of the custom scripts flagged for review. Never the query results
|
|
5
|
+
* underneath — no product names, prices, customers, code or org access.
|
|
6
|
+
*/
|
|
7
|
+
export declare function buildShareSummary(report: ReportData): string;
|
|
8
|
+
//# sourceMappingURL=shareSummary.d.ts.map
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { HUBSPOT_MULTILINE_LIMIT } from '../config/outbound.js';
|
|
2
|
+
/**
|
|
3
|
+
* The compact summary that travels with the meeting-request form: the ten buckets, the mapping,
|
|
4
|
+
* the verdicts and the names of the custom scripts flagged for review. Never the query results
|
|
5
|
+
* underneath — no product names, prices, customers, code or org access.
|
|
6
|
+
*/
|
|
7
|
+
export function buildShareSummary(report) {
|
|
8
|
+
const lines = [];
|
|
9
|
+
lines.push(`Quotivity CPQ Inventory ${report.version} · run ${report.runId} · ${report.generatedAt.slice(0, 10)}`);
|
|
10
|
+
lines.push(`Org: ${report.org.name} · window ${report.window.label} · quote dates on ${report.quoteDateField}`);
|
|
11
|
+
lines.push('');
|
|
12
|
+
lines.push('STAGE 1 · INVENTORY');
|
|
13
|
+
for (const b of report.buckets) {
|
|
14
|
+
const alive = b.alive == null ? '—' : String(b.alive);
|
|
15
|
+
const review = b.review != null ? ` · review ${b.review}` : '';
|
|
16
|
+
lines.push(`${b.name}: exists ${b.exists} · alive ${alive} · needs attention ${b.needsAttention}${review}${b.partial ? ' · partial' : ''}`);
|
|
17
|
+
for (const s of b.summary)
|
|
18
|
+
lines.push(` ${s}`);
|
|
19
|
+
for (const r of b.rows) {
|
|
20
|
+
if (r.count === 0)
|
|
21
|
+
continue;
|
|
22
|
+
lines.push(` - ${r.label}: ${r.count}${r.unit ? ` ${r.unit}` : ''} → ${r.lands} [${r.verdict}${r.shape ? ` · ${r.shape}` : ''}]`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
lines.push('');
|
|
26
|
+
lines.push('STAGE 2 · MIGRATION ANALYSIS');
|
|
27
|
+
for (const t of report.verdictScale)
|
|
28
|
+
lines.push(`${t.label}: ${t.count} (${t.pct}%)`);
|
|
29
|
+
lines.push(`Requires further review: ${report.reviewCount}`);
|
|
30
|
+
for (const m of report.mapping)
|
|
31
|
+
lines.push(`- ${m.bucketName} · ${m.from} → ${m.to} [${m.verdict}${m.shape ? ` · ${m.shape}` : ''}]${m.count ? ` (${m.count})` : ''}`);
|
|
32
|
+
lines.push('');
|
|
33
|
+
lines.push('REQUIRES FURTHER REVIEW');
|
|
34
|
+
for (const r of report.review) {
|
|
35
|
+
lines.push(`- ${r.reason} · ${r.subject}: ${r.question}`);
|
|
36
|
+
if (/Quote Calculator|triggers/i.test(r.subject) && r.names.length)
|
|
37
|
+
lines.push(` scripts: ${r.names.join(', ')}`);
|
|
38
|
+
}
|
|
39
|
+
lines.push('');
|
|
40
|
+
lines.push('PREREQUISITES');
|
|
41
|
+
for (const p of report.prerequisites)
|
|
42
|
+
lines.push(`- ${p.field} (${p.object}) — ${p.need}${p.review ? ' [review]' : ''}`);
|
|
43
|
+
lines.push('');
|
|
44
|
+
lines.push(`Residue: ${report.residue.count} records (${report.residue.pct}%)`);
|
|
45
|
+
if (report.unread.length)
|
|
46
|
+
lines.push(`Unread objects: ${report.unread.map((u) => u.object).join(', ')}`);
|
|
47
|
+
let text = lines.join('\n');
|
|
48
|
+
if (text.length > HUBSPOT_MULTILINE_LIMIT) {
|
|
49
|
+
const marker = '\n… truncated to fit the form field limit';
|
|
50
|
+
text = text.slice(0, HUBSPOT_MULTILINE_LIMIT - marker.length) + marker;
|
|
51
|
+
}
|
|
52
|
+
return text;
|
|
53
|
+
}
|
|
54
|
+
//# sourceMappingURL=shareSummary.js.map
|