@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.
Files changed (45) hide show
  1. package/assets/report/assets/index-CKPi--yh.css +1 -0
  2. package/assets/report/assets/index-CqUjsqpS.js +9 -0
  3. package/assets/report/fonts/LexendDeca-VariableFont_wght.ttf +0 -0
  4. package/assets/report/index.html +14 -0
  5. package/lib/banner.d.ts +7 -0
  6. package/lib/banner.js +13 -0
  7. package/lib/classify/advancedCondition.d.ts +34 -0
  8. package/lib/classify/advancedCondition.js +112 -0
  9. package/lib/classify/formula.d.ts +29 -0
  10. package/lib/classify/formula.js +125 -0
  11. package/lib/classify/index.d.ts +80 -0
  12. package/lib/classify/index.js +662 -0
  13. package/lib/classify/rules.d.ts +31 -0
  14. package/lib/classify/rules.js +868 -0
  15. package/lib/config/outbound.d.ts +31 -0
  16. package/lib/config/outbound.js +35 -0
  17. package/lib/connection.d.ts +42 -0
  18. package/lib/connection.js +52 -0
  19. package/lib/extract/index.d.ts +112 -0
  20. package/lib/extract/index.js +373 -0
  21. package/lib/extract/records.d.ts +421 -0
  22. package/lib/extract/records.js +327 -0
  23. package/lib/index.d.ts +30 -0
  24. package/lib/index.js +51 -0
  25. package/lib/liveness.d.ts +19 -0
  26. package/lib/liveness.js +34 -0
  27. package/lib/mapping.d.ts +14 -0
  28. package/lib/mapping.js +272 -0
  29. package/lib/report/assemble.d.ts +16 -0
  30. package/lib/report/assemble.js +499 -0
  31. package/lib/report/printDoc.d.ts +13 -0
  32. package/lib/report/printDoc.js +82 -0
  33. package/lib/report/shareSummary.d.ts +8 -0
  34. package/lib/report/shareSummary.js +54 -0
  35. package/lib/report/types.d.ts +146 -0
  36. package/lib/report/types.js +2 -0
  37. package/lib/server.d.ts +32 -0
  38. package/lib/server.js +185 -0
  39. package/lib/soql.d.ts +50 -0
  40. package/lib/soql.js +76 -0
  41. package/lib/summary.d.ts +14 -0
  42. package/lib/summary.js +143 -0
  43. package/lib/types.d.ts +22 -0
  44. package/lib/types.js +85 -0
  45. package/package.json +35 -0
@@ -0,0 +1,146 @@
1
+ import type { OutboundConfig } from '../config/outbound.js';
2
+ import type { BucketId, PhaseId, ReviewReason, Shape, Verdict } from '../types.js';
3
+ export interface ReportRow {
4
+ rowId: string;
5
+ label: string;
6
+ count: number;
7
+ /** Unit printed after the count when it is not the row's own record ("14 products"). */
8
+ unit?: string;
9
+ alive: number;
10
+ lands: string;
11
+ verdict: Verdict;
12
+ shape?: Shape;
13
+ review?: ReviewReason;
14
+ /** Present when the row is shown here as a cross-reference; the record is counted in `countedIn`. */
15
+ crossListed?: {
16
+ countedIn: BucketId;
17
+ countedInName: string;
18
+ };
19
+ /** Names of the records, in the admin's vocabulary, for the print document and the share summary. */
20
+ names: string[];
21
+ details: string[];
22
+ }
23
+ export interface ReportBucket {
24
+ id: BucketId;
25
+ name: string;
26
+ question: string;
27
+ phase: PhaseId;
28
+ exists: number;
29
+ /** null for buckets where liveness has no meaning (Custom code & UI). */
30
+ alive: number | null;
31
+ /** Objects whose Alive value is unreliable because of a bulk touch, with the date to print instead. */
32
+ aliveUnreliable: {
33
+ object: string;
34
+ date: string;
35
+ }[];
36
+ needsAttention: number;
37
+ /** Absent (undefined) when the bucket has no review rows — never printed as zero. */
38
+ review?: number;
39
+ /** Exists counts mechanisms (non-empty rows) rather than records. */
40
+ countsMechanisms: boolean;
41
+ summary: string[];
42
+ rows: ReportRow[];
43
+ note: string;
44
+ partial: boolean;
45
+ unread: string[];
46
+ }
47
+ export interface MappingRow {
48
+ id: string;
49
+ bucket: BucketId;
50
+ bucketName: string;
51
+ from: string;
52
+ to: string;
53
+ verdict: Verdict;
54
+ shape?: Shape;
55
+ /** Records in this org that roll up to the row. */
56
+ count: number;
57
+ }
58
+ export interface ReviewRow {
59
+ reason: ReviewReason;
60
+ subject: string;
61
+ count: number;
62
+ question: string;
63
+ outA: string;
64
+ outB: string;
65
+ names: string[];
66
+ }
67
+ export interface Prerequisite {
68
+ field: string;
69
+ object: 'SBQQ__Quote__c' | 'SBQQ__QuoteLine__c';
70
+ need: string;
71
+ review: boolean;
72
+ }
73
+ export interface NoteCard {
74
+ head: string;
75
+ body: string;
76
+ }
77
+ export interface VerdictTile {
78
+ label: Verdict;
79
+ meaning: string;
80
+ count: number;
81
+ /** Share of verdicted mapping rows, as a percentage. */
82
+ pct: number;
83
+ }
84
+ export interface ShapeLegendEntry {
85
+ label: Shape;
86
+ meaning: string;
87
+ count: number;
88
+ }
89
+ export interface ReportData {
90
+ version: string;
91
+ runId: string;
92
+ generatedAt: string;
93
+ org: {
94
+ name: string;
95
+ username: string;
96
+ instanceUrl: string;
97
+ orgId: string;
98
+ };
99
+ window: {
100
+ months: number;
101
+ since: string;
102
+ label: string;
103
+ };
104
+ quoteDateField: string;
105
+ phases: {
106
+ id: PhaseId;
107
+ name: string;
108
+ note: string;
109
+ buckets: BucketId[];
110
+ }[];
111
+ buckets: ReportBucket[];
112
+ verdictScale: VerdictTile[];
113
+ shapeLegend: ShapeLegendEntry[];
114
+ reviewCount: number;
115
+ mapping: MappingRow[];
116
+ review: ReviewRow[];
117
+ notes: NoteCard[];
118
+ prerequisites: Prerequisite[];
119
+ seeded: {
120
+ object: string;
121
+ count: number;
122
+ }[];
123
+ unread: {
124
+ object: string;
125
+ reason: string;
126
+ }[];
127
+ unscanned: string;
128
+ residue: {
129
+ count: number;
130
+ pct: number;
131
+ };
132
+ highlights: string;
133
+ volume: {
134
+ quotesByYear: {
135
+ year: number;
136
+ total: number;
137
+ amendRenew: number;
138
+ }[];
139
+ quotesInWindow: number;
140
+ distinctProductsQuoted: number;
141
+ };
142
+ outbound: OutboundConfig;
143
+ fileName: string;
144
+ sourceUrl: string;
145
+ }
146
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1,32 @@
1
+ import { type Server } from 'node:http';
2
+ import type { ProgressEvent } from './extract/index.js';
3
+ import type { ReportData } from './report/types.js';
4
+ export type RunStatus = 'idle' | 'running' | 'done' | 'error';
5
+ export interface ServerOptions {
6
+ /** Runs the analysis. Called at most once, from POST /api/run — never before the gate. */
7
+ run: (onProgress: (e: ProgressEvent) => void) => Promise<ReportData>;
8
+ org: {
9
+ name: string;
10
+ username: string;
11
+ };
12
+ version: string;
13
+ runId: string;
14
+ port?: number;
15
+ host?: string;
16
+ assetsDir?: string;
17
+ /** Pre-computed report, for tests and for re-serving a saved run. Skips the gate. */
18
+ report?: ReportData;
19
+ log?: (line: string) => void;
20
+ }
21
+ export interface RunningServer {
22
+ server: Server;
23
+ url: string;
24
+ port: number;
25
+ close: () => Promise<void>;
26
+ state: () => {
27
+ status: RunStatus;
28
+ error?: string;
29
+ };
30
+ }
31
+ export declare function startServer(opts: ServerOptions): Promise<RunningServer>;
32
+ //# sourceMappingURL=server.d.ts.map
package/lib/server.js ADDED
@@ -0,0 +1,185 @@
1
+ import { existsSync, readFileSync, statSync } from 'node:fs';
2
+ import { createServer } from 'node:http';
3
+ import { extname, join, resolve } from 'node:path';
4
+ import { outboundConfig } from './config/outbound.js';
5
+ import { ASSETS_DIR, assetsAvailable, buildPrintDocument, MIME } from './report/printDoc.js';
6
+ import { buildShareSummary } from './report/shareSummary.js';
7
+ const HOST = '127.0.0.1';
8
+ const DEFAULT_PORT = 3579;
9
+ export async function startServer(opts) {
10
+ const host = opts.host ?? HOST;
11
+ const assetsDir = resolve(opts.assetsDir ?? ASSETS_DIR);
12
+ const log = opts.log ?? (() => { });
13
+ let status = opts.report ? 'done' : 'idle';
14
+ let report = opts.report;
15
+ let error;
16
+ const progress = [];
17
+ const subscribers = new Set();
18
+ const emit = (e) => {
19
+ progress.push(e);
20
+ const payload = `data: ${JSON.stringify(e)}\n\n`;
21
+ for (const res of subscribers)
22
+ res.write(payload);
23
+ };
24
+ const finish = (kind, detail) => {
25
+ const payload = `event: ${kind}\ndata: ${JSON.stringify({ detail })}\n\n`;
26
+ for (const res of subscribers) {
27
+ res.write(payload);
28
+ res.end();
29
+ }
30
+ subscribers.clear();
31
+ };
32
+ const startRun = () => {
33
+ if (status !== 'idle')
34
+ return;
35
+ status = 'running';
36
+ log('Gate submitted. Reading the org…');
37
+ opts
38
+ .run(emit)
39
+ .then((r) => {
40
+ report = r;
41
+ status = 'done';
42
+ log('Analysis complete.');
43
+ finish('done');
44
+ })
45
+ .catch((err) => {
46
+ status = 'error';
47
+ error = err instanceof Error ? err.message : String(err);
48
+ log(`Analysis failed: ${error}`);
49
+ finish('error', error);
50
+ });
51
+ };
52
+ const json = (res, code, body) => {
53
+ res.writeHead(code, {
54
+ 'content-type': 'application/json; charset=utf-8',
55
+ 'cache-control': 'no-store',
56
+ });
57
+ res.end(JSON.stringify(body));
58
+ };
59
+ const handler = (req, res) => {
60
+ const url = new URL(req.url ?? '/', `http://${host}`);
61
+ const path = url.pathname;
62
+ res.setHeader('x-content-type-options', 'nosniff');
63
+ res.setHeader('referrer-policy', 'no-referrer');
64
+ if (path === '/api/state') {
65
+ return json(res, 200, {
66
+ status,
67
+ error,
68
+ progress,
69
+ org: opts.org,
70
+ version: opts.version,
71
+ runId: opts.runId,
72
+ outbound: outboundConfig(),
73
+ assets: assetsAvailable(assetsDir),
74
+ });
75
+ }
76
+ if (path === '/api/run' && req.method === 'POST') {
77
+ startRun();
78
+ return json(res, 202, { status });
79
+ }
80
+ if (path === '/api/progress') {
81
+ res.writeHead(200, {
82
+ 'content-type': 'text/event-stream',
83
+ 'cache-control': 'no-store',
84
+ connection: 'keep-alive',
85
+ });
86
+ for (const e of progress)
87
+ res.write(`data: ${JSON.stringify(e)}\n\n`);
88
+ if (status === 'done') {
89
+ res.write('event: done\ndata: {}\n\n');
90
+ return res.end();
91
+ }
92
+ if (status === 'error') {
93
+ res.write(`event: error\ndata: ${JSON.stringify({ detail: error })}\n\n`);
94
+ return res.end();
95
+ }
96
+ subscribers.add(res);
97
+ req.on('close', () => subscribers.delete(res));
98
+ return;
99
+ }
100
+ if (path === '/api/report.json') {
101
+ if (!report)
102
+ return json(res, 409, { error: status === 'error' ? error : 'analysis has not run' });
103
+ return json(res, 200, report);
104
+ }
105
+ if (path === '/api/share-summary') {
106
+ if (!report)
107
+ return json(res, 409, { error: 'analysis has not run' });
108
+ return json(res, 200, { summary: buildShareSummary(report) });
109
+ }
110
+ if (path === '/report' && url.searchParams.get('print') === '1') {
111
+ if (!report)
112
+ return json(res, 409, { error: 'analysis has not run' });
113
+ if (!assetsAvailable(assetsDir))
114
+ return json(res, 500, { error: 'report assets are missing from this install' });
115
+ const html = buildPrintDocument(report, assetsDir);
116
+ res.writeHead(200, {
117
+ 'content-type': 'text/html; charset=utf-8',
118
+ 'cache-control': 'no-store',
119
+ });
120
+ return res.end(html);
121
+ }
122
+ if (path.startsWith('/api/'))
123
+ return json(res, 404, { error: 'not found' });
124
+ // Static assets, with SPA fallback to index.html.
125
+ if (!assetsAvailable(assetsDir)) {
126
+ res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
127
+ return res.end('Report assets are missing from this install. Reinstall the package.');
128
+ }
129
+ const clean = decodeURIComponent(path).replace(/^\/+/, '');
130
+ let file = resolve(assetsDir, clean || 'index.html');
131
+ if (!file.startsWith(assetsDir)) {
132
+ res.writeHead(403);
133
+ return res.end();
134
+ }
135
+ if (!existsSync(file) || statSync(file).isDirectory())
136
+ file = join(assetsDir, 'index.html');
137
+ const type = MIME[extname(file).toLowerCase()] ?? 'application/octet-stream';
138
+ res.writeHead(200, {
139
+ 'content-type': type,
140
+ 'cache-control': file.endsWith('index.html') ? 'no-store' : 'public, max-age=3600',
141
+ });
142
+ res.end(readFileSync(file));
143
+ };
144
+ const server = createServer(handler);
145
+ const port = await listen(server, host, opts.port ?? DEFAULT_PORT);
146
+ const address = server.address();
147
+ const url = `http://${host}:${address.port}/`;
148
+ return {
149
+ server,
150
+ url,
151
+ port,
152
+ close: () => new Promise((resolveClose) => {
153
+ for (const s of subscribers)
154
+ s.end();
155
+ subscribers.clear();
156
+ server.close(() => resolveClose());
157
+ }),
158
+ state: () => ({ status, error }),
159
+ };
160
+ }
161
+ /** Binds 127.0.0.1 only. Detects a taken port and increments, up to 20 tries. */
162
+ async function listen(server, host, start) {
163
+ for (let port = start; port < start + 20; port++) {
164
+ const ok = await new Promise((resolveTry) => {
165
+ const onError = (err) => {
166
+ server.removeListener('listening', onListening);
167
+ if (err.code === 'EADDRINUSE' || err.code === 'EACCES')
168
+ resolveTry(false);
169
+ else
170
+ throw err;
171
+ };
172
+ const onListening = () => {
173
+ server.removeListener('error', onError);
174
+ resolveTry(true);
175
+ };
176
+ server.once('error', onError);
177
+ server.once('listening', onListening);
178
+ server.listen(port, host);
179
+ });
180
+ if (ok)
181
+ return port;
182
+ }
183
+ throw new Error(`no free port between ${start} and ${start + 19} on ${host}`);
184
+ }
185
+ //# sourceMappingURL=server.js.map
package/lib/soql.d.ts ADDED
@@ -0,0 +1,50 @@
1
+ import { type OrgConnection } from './connection.js';
2
+ /** Detail retrieval cap per object. Headline counts are never constrained by it. */
3
+ export declare const RETRIEVE_CAP = 10000;
4
+ export type ReadStatus = 'ok' | 'absent' | 'unread';
5
+ export interface ObjectRead<T extends object = Record<string, unknown>> {
6
+ object: string;
7
+ status: ReadStatus;
8
+ /** Exact count from the aggregate query (0 when absent or unread). */
9
+ count: number;
10
+ records: T[];
11
+ /** True when the object holds more records than were retrieved. */
12
+ partial: boolean;
13
+ /** Why the object is absent or unread. */
14
+ reason?: string;
15
+ }
16
+ export interface ReadOptions {
17
+ where?: string;
18
+ orderBy?: string;
19
+ cap?: number;
20
+ tooling?: boolean;
21
+ /**
22
+ * Classifier inputs cannot be truncated — a partial set produces wrong bucket assignments.
23
+ * When set, exceeding the cap throws instead of returning a partial read.
24
+ */
25
+ classifierInput?: boolean;
26
+ /** Objects known to be optional (namespace may be absent). */
27
+ optional?: boolean;
28
+ }
29
+ export declare class ClassifierInputTooLargeError extends Error {
30
+ readonly object: string;
31
+ readonly count: number;
32
+ constructor(object: string, count: number);
33
+ }
34
+ export declare function soqlDate(d: Date): string;
35
+ export declare function quote(value: string): string;
36
+ export declare function inList(ids: Iterable<string>): string;
37
+ export declare function count(conn: OrgConnection, object: string, where?: string, tooling?: boolean): Promise<number>;
38
+ /**
39
+ * Reads one object: an aggregate COUNT() for the headline figure, then the records up to the cap.
40
+ * Missing objects (a namespace that is not installed) come back `absent`; transport failures after
41
+ * retries come back `unread`. Neither is ever reported as zero.
42
+ */
43
+ export declare function readObject<T extends object = Record<string, unknown>>(conn: OrgConnection, object: string, fields: readonly string[], opts?: ReadOptions): Promise<ObjectRead<T>>;
44
+ /** Aggregate query returning grouped rows; absent/unread collapse to an empty list. */
45
+ export declare function aggregate<T extends object>(conn: OrgConnection, soql: string, tooling?: boolean): Promise<{
46
+ rows: T[];
47
+ status: ReadStatus;
48
+ reason?: string;
49
+ }>;
50
+ //# sourceMappingURL=soql.d.ts.map
package/lib/soql.js ADDED
@@ -0,0 +1,76 @@
1
+ import { errorText, isMissingObjectError, withRetry } from './connection.js';
2
+ /** Detail retrieval cap per object. Headline counts are never constrained by it. */
3
+ export const RETRIEVE_CAP = 10_000;
4
+ export class ClassifierInputTooLargeError extends Error {
5
+ object;
6
+ count;
7
+ constructor(object, count) {
8
+ super(`${object} holds ${count} records, more than the ${RETRIEVE_CAP} the classifier can read in full. The run stops rather than classifying a partial set.`);
9
+ this.object = object;
10
+ this.count = count;
11
+ this.name = 'ClassifierInputTooLargeError';
12
+ }
13
+ }
14
+ export function soqlDate(d) {
15
+ return d.toISOString();
16
+ }
17
+ export function quote(value) {
18
+ return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
19
+ }
20
+ export function inList(ids) {
21
+ return `(${[...ids].map(quote).join(',')})`;
22
+ }
23
+ export async function count(conn, object, where, tooling = false) {
24
+ const soql = `SELECT COUNT() FROM ${object}${where ? ` WHERE ${where}` : ''}`;
25
+ const res = await withRetry(() => conn.query(soql, { tooling }));
26
+ return res.totalSize;
27
+ }
28
+ function empty(object, status, reason) {
29
+ return { object, status, count: 0, records: [], partial: false, reason };
30
+ }
31
+ /**
32
+ * Reads one object: an aggregate COUNT() for the headline figure, then the records up to the cap.
33
+ * Missing objects (a namespace that is not installed) come back `absent`; transport failures after
34
+ * retries come back `unread`. Neither is ever reported as zero.
35
+ */
36
+ export async function readObject(conn, object, fields, opts = {}) {
37
+ const cap = opts.cap ?? RETRIEVE_CAP;
38
+ const where = opts.where ? ` WHERE ${opts.where}` : '';
39
+ const orderBy = opts.orderBy ? ` ORDER BY ${opts.orderBy}` : '';
40
+ try {
41
+ const total = await count(conn, object, opts.where, opts.tooling);
42
+ if (opts.classifierInput && total > cap)
43
+ throw new ClassifierInputTooLargeError(object, total);
44
+ if (total === 0)
45
+ return { object, status: 'ok', count: 0, records: [], partial: false };
46
+ const soql = `SELECT ${fields.join(', ')} FROM ${object}${where}${orderBy} LIMIT ${cap}`;
47
+ const res = await withRetry(() => conn.query(soql, { tooling: opts.tooling }));
48
+ return {
49
+ object,
50
+ status: 'ok',
51
+ count: total,
52
+ records: res.records,
53
+ partial: total > res.records.length,
54
+ };
55
+ }
56
+ catch (err) {
57
+ if (err instanceof ClassifierInputTooLargeError)
58
+ throw err;
59
+ if (isMissingObjectError(err))
60
+ return empty(object, 'absent', errorText(err));
61
+ return empty(object, 'unread', errorText(err));
62
+ }
63
+ }
64
+ /** Aggregate query returning grouped rows; absent/unread collapse to an empty list. */
65
+ export async function aggregate(conn, soql, tooling = false) {
66
+ try {
67
+ const res = await withRetry(() => conn.query(soql, { tooling }));
68
+ return { rows: res.records, status: 'ok' };
69
+ }
70
+ catch (err) {
71
+ if (isMissingObjectError(err))
72
+ return { rows: [], status: 'absent', reason: errorText(err) };
73
+ return { rows: [], status: 'unread', reason: errorText(err) };
74
+ }
75
+ }
76
+ //# sourceMappingURL=soql.js.map
@@ -0,0 +1,14 @@
1
+ import type { Classification } from './classify/index.js';
2
+ import type { Extraction } from './extract/index.js';
3
+ import type { BucketId } from './types.js';
4
+ export interface SummaryInput {
5
+ ex: Extraction;
6
+ cl: Classification;
7
+ rowCount: (rowId: string) => number;
8
+ bucketAlive: (id: BucketId) => number | null;
9
+ mechanisms: number;
10
+ }
11
+ /** One line per bucket, in the admin's vocabulary, counts first. */
12
+ export declare function summaryLines(id: BucketId, s: SummaryInput): string[];
13
+ export declare function bucketNote(id: BucketId, s: SummaryInput): string;
14
+ //# sourceMappingURL=summary.d.ts.map
package/lib/summary.js ADDED
@@ -0,0 +1,143 @@
1
+ const n = (v) => v.toLocaleString('en-US');
2
+ const plural = (v, one, many = `${one}s`) => `${n(v)} ${v === 1 ? one : many}`;
3
+ /** One line per bucket, in the admin's vocabulary, counts first. */
4
+ export function summaryLines(id, s) {
5
+ const { ex, cl, rowCount } = s;
6
+ const f = cl.facts;
7
+ const years = ex.windowMonths === 12 ? 'the last 12 months' : `the trailing ${ex.windowMonths} months`;
8
+ switch (id) {
9
+ case 'catalog': {
10
+ const cur = f.multiCurrency
11
+ ? `${f.currencies.length} currencies enabled.`
12
+ : 'single currency.';
13
+ return [
14
+ `${plural(ex.product2.count, 'active product')} across ${plural(ex.pricebook2.count, 'price book')}, ${cur}`,
15
+ `${n(f.productsNeverQuoted)} never appeared on a quote line in ${years}.`,
16
+ ];
17
+ }
18
+ case 'configuration': {
19
+ const dormant = ex.productFeature.records.filter((r) => !r.LastModifiedDate || r.LastModifiedDate < ex.since).length;
20
+ const filters = rowCount('cfg.filterRules');
21
+ return [
22
+ `${plural(f.bundles, 'bundle')}, ${plural(f.featureCount, 'bundle option')}, deepest ${plural(f.nestingDepth, 'level')}.`,
23
+ `${n(dormant)} not configured since ${ex.since.slice(0, 4)}. ${n(filters)} use dynamic option sets.`,
24
+ ];
25
+ }
26
+ case 'discovery':
27
+ return [
28
+ `${plural(f.attributeCount, 'configuration attribute')}, ${plural(ex.quoteProcess.count, 'guided selling process', 'guided selling processes')}, ${plural(ex.processInput.count, 'process input')}.`,
29
+ `${plural(rowCount('disc.priceRulesDefaulting'), 'price rule')} exist only to default a field.`,
30
+ ];
31
+ case 'price': {
32
+ const qcp = ex.customScript.count;
33
+ return [
34
+ `${n(s.mechanisms)} distinct ${s.mechanisms === 1 ? 'way' : 'ways'} a price reaches a line.`,
35
+ qcp
36
+ ? `${n(qcp)} of the calculation path${qcp === 1 ? ' runs' : 's run'} through a Quote Calculator Plugin.`
37
+ : 'None of them run through a Quote Calculator Plugin.',
38
+ ];
39
+ }
40
+ case 'discounting': {
41
+ const above = rowCount('dsc.quoteScope') + rowCount('dsc.groupScope');
42
+ return [
43
+ `${plural(ex.discountSchedule.count, 'discount schedule')}, ${plural(f.discountTierCount, 'tier')}, ${plural(ex.contractedPrice.count, 'contracted price')}.`,
44
+ `${n(above)} schedule${above === 1 ? '' : 's'} aggregate above the line rather than on it.`,
45
+ ];
46
+ }
47
+ case 'guardrails': {
48
+ const rules = rowCount('grd.validation') +
49
+ rowCount('grd.alert') +
50
+ rowCount('grd.quoteScope') +
51
+ rowCount('grd.platformValidation');
52
+ const floors = rowCount('grd.floorCeiling');
53
+ const alive = s.bucketAlive('guardrails');
54
+ return [
55
+ `${plural(rules, 'rule')} that stop or warn a rep.${alive != null ? ` ${n(alive)} touched in ${years}.` : ''}`,
56
+ `${plural(floors, 'margin floor or ceiling')} ${floors === 1 ? 'is' : 'are'} computed by a price rule a validation rule then tests.`,
57
+ ];
58
+ }
59
+ case 'approvals': {
60
+ const cycle = f.medianApprovalCycleDays != null
61
+ ? ` Median cycle ${f.medianApprovalCycleDays.toFixed(1)} days.`
62
+ : '';
63
+ const engines = f.advancedApprovalsInstalled
64
+ ? f.nativeApprovalProcesses
65
+ ? `Advanced Approvals and ${plural(f.nativeApprovalProcesses, 'native approval process', 'native approval processes')} are both live.`
66
+ : 'Advanced Approvals is the only engine in use.'
67
+ : f.nativeApprovalProcesses
68
+ ? `Advanced Approvals is not installed. ${plural(f.nativeApprovalProcesses, 'native approval process', 'native approval processes')} on Quote or Opportunity.`
69
+ : 'Advanced Approvals is not installed and no native approval process runs on Quote or Opportunity.';
70
+ return [
71
+ `${plural(ex.approvalRule.count, 'approval rule')}, ${plural(ex.approvalChain.count, 'chain')}, ${plural(f.approverCount, 'approver')}.${cycle}`,
72
+ engines,
73
+ ];
74
+ }
75
+ case 'output': {
76
+ const stale = ex.quoteTemplate.records.filter((r) => !r.LastModifiedDate || r.LastModifiedDate < ex.since).length;
77
+ return [
78
+ `${plural(ex.quoteTemplate.count, 'template')}, ${plural(f.templateSectionCount, 'section')}, ${plural(ex.lineColumn.count, 'line column')}, ${plural(ex.quoteTerm.count, 'quote term')}.`,
79
+ `${n(stale)} template${stale === 1 ? ' has' : 's have'} not been touched since ${ex.since.slice(0, 4)}.`,
80
+ ];
81
+ }
82
+ case 'lifecycle': {
83
+ const share = f.amendRenewShareLastYear != null && f.lastYear != null
84
+ ? `${Math.round(f.amendRenewShareLastYear * 100)}% of ${f.lastYear}'s quotes were renewals or amendments.`
85
+ : 'No quotes dated last year, so the renewal share cannot be measured.';
86
+ return [
87
+ `${plural(ex.contractActive, 'active contract')}, ${plural(f.subscriptionCount, 'subscription')}.`,
88
+ share,
89
+ ];
90
+ }
91
+ case 'code':
92
+ return [
93
+ `${plural(ex.customScript.count, 'Quote Calculator Plugin')}, ${plural(ex.apexTrigger.count, 'trigger')}, ${plural(ex.customAction.count, 'custom action')}, ${plural(rowCount('code.ui'), 'UI customisation')}.`,
94
+ 'Named and flagged for review. Not interpreted.',
95
+ ];
96
+ }
97
+ }
98
+ export function bucketNote(id, s) {
99
+ const { ex, cl } = s;
100
+ const f = cl.facts;
101
+ switch (id) {
102
+ case 'catalog':
103
+ return 'Counts come from aggregate queries. PricebookEntry records are never retrieved — a product with no price in a currency is a catalogue data problem, not a migration finding.';
104
+ case 'configuration': {
105
+ const nested = s.rowCount('cfg.optionsNested');
106
+ return nested
107
+ ? `Nesting has no target — Quotivity bundles are one level deep, so ${n(nested)} option product${nested === 1 ? '' : 's'} ha${nested === 1 ? 's' : 've'} to be flattened or split.`
108
+ : 'No nested bundles. Every option product is a leaf, which is the shape Quotivity bundles expect.';
109
+ }
110
+ case 'discovery':
111
+ return 'Attributes split by whether the answer is pushed onto product options — that decides which of the two destinations applies.';
112
+ case 'price':
113
+ return `This bucket counts mechanisms, not records. ${s.mechanisms > 6 ? `${n(s.mechanisms)} is high — most orgs run three or four.` : 'Most orgs run three or four.'}`;
114
+ case 'discounting':
115
+ return `${plural(f.discountTierCount, 'tier')} are read but not counted as constructs — they belong to their schedule. Tier bounds are half-open in CPQ and stay that way on import.`;
116
+ case 'guardrails': {
117
+ const seeded = ex.validationRule.records.length;
118
+ return `Platform validation rules on CPQ objects are counted only when admin-authored (${n(seeded)} here). Package-owned rules are excluded by namespace.`;
119
+ }
120
+ case 'approvals':
121
+ return f.advancedApprovalsInstalled && f.nativeApprovalProcesses
122
+ ? 'Both engines are running. A SOQL-only inventory would have missed the native processes entirely.'
123
+ : 'Native approval processes are read from ProcessDefinition, so a native-only org is not reported as zero.';
124
+ case 'output':
125
+ return 'Templates are authored fresh rather than imported — same document, no import file. That is true of a move to Revenue Cloud Advanced too.';
126
+ case 'lifecycle':
127
+ return f.amendRenewShareLastYear != null && f.amendRenewShareLastYear >= 0.5
128
+ ? 'Renewals and amendments are the majority of quoting volume here, so this bucket carries more weight than its record count suggests.'
129
+ : 'Contracts and subscriptions carry to HubSpot Contracts; orders and assets have no target.';
130
+ case 'code': {
131
+ const seeded = Object.entries(ex.seeded)
132
+ .filter(([o]) => /CustomAction|CustomScript/.test(o))
133
+ .map(([, c]) => c)
134
+ .reduce((a, b) => a + b, 0);
135
+ const names = Object.keys(f.qcpHooks);
136
+ const qcpNote = names.length
137
+ ? ` ${names.join(', ')} — ${names.length > 3 ? 'several means pricing outgrew the rules engine years ago, itself a finding.' : 'most orgs have zero to three.'}`
138
+ : '';
139
+ return `Package-owned records are excluded: ${n(seeded)} custom actions and scripts shipped with CPQ, plus every namespaced trigger, button, layout and record page.${qcpNote}`;
140
+ }
141
+ }
142
+ }
143
+ //# sourceMappingURL=summary.js.map
package/lib/types.d.ts ADDED
@@ -0,0 +1,22 @@
1
+ /** Verdict scale from the spec. "Further review" is not a verdict but travels in the same field. */
2
+ export type Verdict = 'Clear path' | 'Degraded' | 'No target' | 'Further review';
3
+ export type Shape = '1:1' | 'Fan-out' | 'Rebuild';
4
+ export type ReviewReason = 'Unread' | 'Intent';
5
+ export type BucketId = 'catalog' | 'configuration' | 'discovery' | 'price' | 'discounting' | 'guardrails' | 'approvals' | 'output' | 'lifecycle' | 'code';
6
+ export interface BucketDef {
7
+ id: BucketId;
8
+ name: string;
9
+ question: string;
10
+ phase: PhaseId;
11
+ }
12
+ export type PhaseId = 'before' | 'quote' | 'after' | 'outside';
13
+ export interface PhaseDef {
14
+ id: PhaseId;
15
+ name: string;
16
+ note: string;
17
+ buckets: BucketId[];
18
+ }
19
+ export declare const BUCKETS: readonly BucketDef[];
20
+ export declare const PHASES: readonly PhaseDef[];
21
+ export declare function bucketDef(id: BucketId): BucketDef;
22
+ //# sourceMappingURL=types.d.ts.map