@f5xc-salesdemos/xcsh 18.69.0 → 18.72.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/package.json +7 -7
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/pipeline-report/generator.ts +355 -27
- package/src/pipeline-report/renderer.ts +177 -34
- package/src/pipeline-report/types.ts +64 -0
- package/src/prompts/tools/sf-query.md +20 -17
- package/src/prompts/tools/xcsh-api.md +3 -0
- package/src/tools/sf/formatters.ts +48 -0
- package/src/tools/sf-renderer.ts +34 -20
- package/src/tools/sf.ts +10 -2
- package/src/tools/xcsh-api.ts +187 -59
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5xc-salesdemos/xcsh",
|
|
4
|
-
"version": "18.
|
|
4
|
+
"version": "18.72.0",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://github.com/f5xc-salesdemos/xcsh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -48,12 +48,12 @@
|
|
|
48
48
|
"dependencies": {
|
|
49
49
|
"@agentclientprotocol/sdk": "0.16.1",
|
|
50
50
|
"@mozilla/readability": "^0.6",
|
|
51
|
-
"@f5xc-salesdemos/xcsh-stats": "18.
|
|
52
|
-
"@f5xc-salesdemos/pi-agent-core": "18.
|
|
53
|
-
"@f5xc-salesdemos/pi-ai": "18.
|
|
54
|
-
"@f5xc-salesdemos/pi-natives": "18.
|
|
55
|
-
"@f5xc-salesdemos/pi-tui": "18.
|
|
56
|
-
"@f5xc-salesdemos/pi-utils": "18.
|
|
51
|
+
"@f5xc-salesdemos/xcsh-stats": "18.72.0",
|
|
52
|
+
"@f5xc-salesdemos/pi-agent-core": "18.72.0",
|
|
53
|
+
"@f5xc-salesdemos/pi-ai": "18.72.0",
|
|
54
|
+
"@f5xc-salesdemos/pi-natives": "18.72.0",
|
|
55
|
+
"@f5xc-salesdemos/pi-tui": "18.72.0",
|
|
56
|
+
"@f5xc-salesdemos/pi-utils": "18.72.0",
|
|
57
57
|
"@sinclair/typebox": "^0.34",
|
|
58
58
|
"@xterm/headless": "^6.0",
|
|
59
59
|
"ajv": "^8.18",
|
|
@@ -17,17 +17,17 @@ export interface BuildInfo {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export const BUILD_INFO: BuildInfo = {
|
|
20
|
-
"version": "18.
|
|
21
|
-
"commit": "
|
|
22
|
-
"shortCommit": "
|
|
20
|
+
"version": "18.72.0",
|
|
21
|
+
"commit": "ee865e583dfa21db5c5f5bf07001570c5f7f9086",
|
|
22
|
+
"shortCommit": "ee865e5",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v18.
|
|
25
|
-
"commitDate": "2026-05-
|
|
26
|
-
"buildDate": "2026-05-
|
|
24
|
+
"tag": "v18.72.0",
|
|
25
|
+
"commitDate": "2026-05-19T19:45:52Z",
|
|
26
|
+
"buildDate": "2026-05-19T20:24:28.090Z",
|
|
27
27
|
"dirty": false,
|
|
28
28
|
"prNumber": "",
|
|
29
29
|
"repoUrl": "https://github.com/f5xc-salesdemos/xcsh",
|
|
30
30
|
"repoSlug": "f5xc-salesdemos/xcsh",
|
|
31
|
-
"commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/
|
|
32
|
-
"releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.
|
|
31
|
+
"commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/ee865e583dfa21db5c5f5bf07001570c5f7f9086",
|
|
32
|
+
"releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.72.0"
|
|
33
33
|
};
|
|
@@ -2,9 +2,12 @@ import { logger } from "@f5xc-salesdemos/pi-utils";
|
|
|
2
2
|
import { $ } from "bun";
|
|
3
3
|
import type {
|
|
4
4
|
AccountRow,
|
|
5
|
+
CloseMonthBucket,
|
|
5
6
|
DataAnomaly,
|
|
7
|
+
DealSummary,
|
|
6
8
|
ForecastSummary,
|
|
7
9
|
LineItemRecord,
|
|
10
|
+
PipelineChange,
|
|
8
11
|
PipelineReportData,
|
|
9
12
|
PipelineReportOptions,
|
|
10
13
|
SectionData,
|
|
@@ -67,6 +70,12 @@ function parseLineItem(record: Record<string, unknown>, rules: SkuClassification
|
|
|
67
70
|
skuName,
|
|
68
71
|
fyb: (record.FYB_Total_Price__c as number) ?? 0,
|
|
69
72
|
category: classifySku(skuName, rules),
|
|
73
|
+
closeDate: (opp.CloseDate as string) ?? undefined,
|
|
74
|
+
oppName: (opp.Name as string) ?? undefined,
|
|
75
|
+
stage: (opp.StageName as string) ?? undefined,
|
|
76
|
+
lastActivityDate: (opp.LastActivityDate as string) ?? undefined,
|
|
77
|
+
ownerName: ((opp.Owner as Record<string, unknown> | undefined)?.Name as string) ?? undefined,
|
|
78
|
+
nextStep: (opp.NextStep as string) ?? undefined,
|
|
70
79
|
};
|
|
71
80
|
}
|
|
72
81
|
|
|
@@ -139,6 +148,116 @@ function buildForecast(items: LineItemRecord[]): ForecastSummary {
|
|
|
139
148
|
return { commit: buckets.Commit, bestCase: buckets["Best Case"], pipeline: buckets.Pipeline };
|
|
140
149
|
}
|
|
141
150
|
|
|
151
|
+
/** Aggregate net new line items by opportunity and return the top N by quota-eligible amount. */
|
|
152
|
+
function buildTopDeals(items: LineItemRecord[], limit = 5): DealSummary[] {
|
|
153
|
+
const oppMap = new Map<
|
|
154
|
+
string,
|
|
155
|
+
{
|
|
156
|
+
name: string;
|
|
157
|
+
accountName: string;
|
|
158
|
+
stage: string;
|
|
159
|
+
closeDate: string;
|
|
160
|
+
forecast: string;
|
|
161
|
+
amount: number;
|
|
162
|
+
ownerName: string;
|
|
163
|
+
nextStep: string;
|
|
164
|
+
}
|
|
165
|
+
>();
|
|
166
|
+
for (const item of items) {
|
|
167
|
+
if (item.category === "other" || item.fyb <= 0) continue;
|
|
168
|
+
const existing = oppMap.get(item.opportunityId);
|
|
169
|
+
if (existing) {
|
|
170
|
+
existing.amount += item.fyb;
|
|
171
|
+
} else {
|
|
172
|
+
oppMap.set(item.opportunityId, {
|
|
173
|
+
name: item.oppName ?? item.accountName,
|
|
174
|
+
accountName: item.accountName,
|
|
175
|
+
stage: item.stage ?? "",
|
|
176
|
+
closeDate: item.closeDate ?? "",
|
|
177
|
+
forecast: item.forecast,
|
|
178
|
+
amount: item.fyb,
|
|
179
|
+
ownerName: item.ownerName ?? "",
|
|
180
|
+
nextStep: item.nextStep ?? "",
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return [...oppMap.entries()]
|
|
185
|
+
.sort(([, a], [, b]) => b.amount - a.amount)
|
|
186
|
+
.slice(0, limit)
|
|
187
|
+
.map(([id, d]) => ({
|
|
188
|
+
oppId: id,
|
|
189
|
+
name: d.name,
|
|
190
|
+
accountName: d.accountName,
|
|
191
|
+
stage: d.stage,
|
|
192
|
+
closeDate: d.closeDate,
|
|
193
|
+
forecast: d.forecast,
|
|
194
|
+
amount: d.amount,
|
|
195
|
+
ownerName: d.ownerName,
|
|
196
|
+
nextStep: d.nextStep || undefined,
|
|
197
|
+
}));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Build close-date distribution buckets from net new line items (quota-eligible only). */
|
|
201
|
+
function buildCloseDistribution(items: LineItemRecord[]): CloseMonthBucket[] {
|
|
202
|
+
const buckets = new Map<
|
|
203
|
+
string,
|
|
204
|
+
{ amount: number; commit: number; bestCase: number; pipeline: number; oppIds: Set<string> }
|
|
205
|
+
>();
|
|
206
|
+
for (const item of items) {
|
|
207
|
+
if (item.category === "other" || item.fyb <= 0 || !item.closeDate) continue;
|
|
208
|
+
const ym = item.closeDate.slice(0, 7); // YYYY-MM
|
|
209
|
+
const existing = buckets.get(ym) ?? {
|
|
210
|
+
amount: 0,
|
|
211
|
+
commit: 0,
|
|
212
|
+
bestCase: 0,
|
|
213
|
+
pipeline: 0,
|
|
214
|
+
oppIds: new Set<string>(),
|
|
215
|
+
};
|
|
216
|
+
existing.amount += item.fyb;
|
|
217
|
+
existing.oppIds.add(item.opportunityId);
|
|
218
|
+
if (item.forecast === "Commit") existing.commit += item.fyb;
|
|
219
|
+
else if (item.forecast === "Best Case") existing.bestCase += item.fyb;
|
|
220
|
+
else if (item.forecast === "Pipeline") existing.pipeline += item.fyb;
|
|
221
|
+
buckets.set(ym, existing);
|
|
222
|
+
}
|
|
223
|
+
return [...buckets.entries()]
|
|
224
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
225
|
+
.map(([ym, data]) => {
|
|
226
|
+
const d = new Date(`${ym}-01T00:00:00`);
|
|
227
|
+
const label = d.toLocaleDateString("en-US", { month: "long", year: "numeric" });
|
|
228
|
+
return {
|
|
229
|
+
label,
|
|
230
|
+
yearMonth: ym,
|
|
231
|
+
amount: data.amount,
|
|
232
|
+
commit: data.commit,
|
|
233
|
+
bestCase: data.bestCase,
|
|
234
|
+
pipeline: data.pipeline,
|
|
235
|
+
oppCount: data.oppIds.size,
|
|
236
|
+
};
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Parse OpportunityFieldHistory records into PipelineChange entries. */
|
|
241
|
+
function parseHistoryRecords(records: Record<string, unknown>[]): PipelineChange[] {
|
|
242
|
+
return records
|
|
243
|
+
.map((r): PipelineChange | null => {
|
|
244
|
+
const opp = (r.Opportunity ?? {}) as Record<string, unknown>;
|
|
245
|
+
const acct = (opp.Account ?? {}) as Record<string, unknown>;
|
|
246
|
+
const field = r.Field as string;
|
|
247
|
+
if (field !== "Amount" && field !== "ForecastCategoryName" && field !== "StageName") return null;
|
|
248
|
+
return {
|
|
249
|
+
oppId: (r.OpportunityId as string) ?? "",
|
|
250
|
+
dealName: ((opp.Name as string) ?? "").trim(),
|
|
251
|
+
accountName: ((acct.Name as string) ?? "").trim(),
|
|
252
|
+
field,
|
|
253
|
+
oldValue: String(r.OldValue ?? "\u2014"),
|
|
254
|
+
newValue: String(r.NewValue ?? "\u2014"),
|
|
255
|
+
date: ((r.CreatedDate as string) ?? "").slice(0, 10),
|
|
256
|
+
};
|
|
257
|
+
})
|
|
258
|
+
.filter((c): c is PipelineChange => c !== null);
|
|
259
|
+
}
|
|
260
|
+
|
|
142
261
|
// ---------------------------------------------------------------------------
|
|
143
262
|
// Anomaly detection
|
|
144
263
|
// ---------------------------------------------------------------------------
|
|
@@ -146,6 +265,7 @@ function buildForecast(items: LineItemRecord[]): ForecastSummary {
|
|
|
146
265
|
function detectAnomalies(
|
|
147
266
|
netNew: LineItemRecord[],
|
|
148
267
|
booked: LineItemRecord[],
|
|
268
|
+
renewals: LineItemRecord[],
|
|
149
269
|
classification: SkuClassification,
|
|
150
270
|
): DataAnomaly[] {
|
|
151
271
|
const anomalies: DataAnomaly[] = [];
|
|
@@ -210,6 +330,93 @@ function detectAnomalies(
|
|
|
210
330
|
});
|
|
211
331
|
}
|
|
212
332
|
|
|
333
|
+
// 5. Open deals with close dates that have slipped past TODAY (requires CloseDate in data)
|
|
334
|
+
const today = new Date().toISOString().split("T")[0]!;
|
|
335
|
+
const slippedAccounts = new Map<string, string>(); // accountName → earliest slipped closeDate
|
|
336
|
+
for (const item of netNew) {
|
|
337
|
+
if (item.closeDate && item.closeDate < today) {
|
|
338
|
+
const existing = slippedAccounts.get(item.accountName);
|
|
339
|
+
if (!existing || item.closeDate < existing) {
|
|
340
|
+
slippedAccounts.set(item.accountName, item.closeDate);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
if (slippedAccounts.size > 0) {
|
|
345
|
+
const details = [...slippedAccounts.entries()]
|
|
346
|
+
.sort(([, a], [, b]) => a.localeCompare(b))
|
|
347
|
+
.map(([name, date]) => `${name} (${date})`)
|
|
348
|
+
.join(", ");
|
|
349
|
+
anomalies.push({
|
|
350
|
+
severity: "warning",
|
|
351
|
+
category: "slipped-close-date",
|
|
352
|
+
message: `${slippedAccounts.size} account(s) have open pipeline with close dates in the past`,
|
|
353
|
+
details,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// 6. Stalled deals — open pipeline with no activity in >30 days
|
|
358
|
+
// Use the MOST RECENT activity date per account — if any deal has recent activity,
|
|
359
|
+
// the account is not stalled.
|
|
360
|
+
const staleThreshold = new Date();
|
|
361
|
+
staleThreshold.setDate(staleThreshold.getDate() - 30);
|
|
362
|
+
const staleStr = staleThreshold.toISOString().split("T")[0]!;
|
|
363
|
+
const acctLastActivity = new Map<string, string | null>(); // accountName → best lastActivityDate
|
|
364
|
+
for (const item of netNew) {
|
|
365
|
+
const existing = acctLastActivity.get(item.accountName);
|
|
366
|
+
const lad = item.lastActivityDate ?? null;
|
|
367
|
+
if (existing === undefined) {
|
|
368
|
+
acctLastActivity.set(item.accountName, lad);
|
|
369
|
+
} else if (lad && (!existing || lad > existing)) {
|
|
370
|
+
acctLastActivity.set(item.accountName, lad);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
const stalledAccounts = new Map<string, string>();
|
|
374
|
+
for (const [name, lad] of acctLastActivity) {
|
|
375
|
+
if (!lad) {
|
|
376
|
+
stalledAccounts.set(name, "(no activity recorded)");
|
|
377
|
+
} else if (lad < staleStr) {
|
|
378
|
+
stalledAccounts.set(name, lad);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
if (stalledAccounts.size > 0) {
|
|
382
|
+
const details = [...stalledAccounts.entries()]
|
|
383
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
384
|
+
.map(([name, date]) => `${name} (${date})`)
|
|
385
|
+
.join(", ");
|
|
386
|
+
anomalies.push({
|
|
387
|
+
severity: "warning",
|
|
388
|
+
category: "stalled-deals",
|
|
389
|
+
message: `${stalledAccounts.size} account(s) have open pipeline with no activity in >30 days`,
|
|
390
|
+
details,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// 7. Urgent renewals — closing within 30 days
|
|
395
|
+
const urgentThreshold = new Date();
|
|
396
|
+
urgentThreshold.setDate(urgentThreshold.getDate() + 30);
|
|
397
|
+
const urgentStr = urgentThreshold.toISOString().split("T")[0]!;
|
|
398
|
+
const urgentRenewals = new Map<string, string>(); // accountName → closeDate
|
|
399
|
+
for (const item of renewals) {
|
|
400
|
+
if (item.closeDate && item.closeDate <= urgentStr) {
|
|
401
|
+
const existing = urgentRenewals.get(item.accountName);
|
|
402
|
+
if (!existing || item.closeDate < existing) {
|
|
403
|
+
urgentRenewals.set(item.accountName, item.closeDate);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (urgentRenewals.size > 0) {
|
|
408
|
+
const details = [...urgentRenewals.entries()]
|
|
409
|
+
.sort(([, a], [, b]) => a.localeCompare(b))
|
|
410
|
+
.map(([name, date]) => `${name} (${date})`)
|
|
411
|
+
.join(", ");
|
|
412
|
+
anomalies.push({
|
|
413
|
+
severity: "warning",
|
|
414
|
+
category: "urgent-renewals",
|
|
415
|
+
message: `${urgentRenewals.size} account(s) have renewals closing within 30 days`,
|
|
416
|
+
details,
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
213
420
|
return anomalies;
|
|
214
421
|
}
|
|
215
422
|
|
|
@@ -231,42 +438,38 @@ export async function generatePipelineReport(options: PipelineReportOptions): Pr
|
|
|
231
438
|
const staleCutoff = options.staleCutoff;
|
|
232
439
|
|
|
233
440
|
const userFilter = buildUserFilter(userIds);
|
|
234
|
-
|
|
235
|
-
|
|
441
|
+
// SOQL does not allow OR between a semi-join subselect and other fields inside
|
|
442
|
+
// parentheses ("Semi join sub-selects are only allowed at the top level").
|
|
443
|
+
// The OpportunityTeamMember subselect already covers both SE and AE via userIds.
|
|
236
444
|
const skuFilter = buildSkuFilter(skuPrefixes);
|
|
237
445
|
|
|
238
446
|
const fields = [
|
|
239
447
|
"Product2.Name",
|
|
240
448
|
"FYB_Total_Price__c",
|
|
241
449
|
"Opportunity.Id",
|
|
450
|
+
"Opportunity.Name",
|
|
242
451
|
"Opportunity.Account.Name",
|
|
243
452
|
"Opportunity.Territory_Credited_District_del__c",
|
|
244
453
|
"Opportunity.ForecastCategoryName",
|
|
454
|
+
"Opportunity.StageName",
|
|
455
|
+
"Opportunity.IsClosed",
|
|
456
|
+
"Opportunity.IsWon",
|
|
457
|
+
"Opportunity.CloseDate",
|
|
458
|
+
"Opportunity.LastActivityDate",
|
|
459
|
+
"Opportunity.Owner.Name",
|
|
460
|
+
"Opportunity.NextStep",
|
|
245
461
|
].join(", ");
|
|
246
462
|
const commonFilters = `Subscription_Renewal__c = false AND Opportunity.ForecastCategoryName != 'Omitted' AND FYB_Total_Price__c > 0 AND (${skuFilter})`;
|
|
247
463
|
const quarterDateFilter = `Opportunity.CloseDate >= ${quarterStart} AND Opportunity.CloseDate <= ${quarterEnd}`;
|
|
248
464
|
const inPlayDateFilter = staleCutoff ? `Opportunity.CloseDate >= ${staleCutoff}` : quarterDateFilter;
|
|
249
465
|
|
|
250
|
-
const
|
|
466
|
+
const oliTeamScope = `OpportunityId IN (SELECT OpportunityId FROM OpportunityTeamMember WHERE ${userFilter})`;
|
|
251
467
|
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
const
|
|
255
|
-
runSfQuery(
|
|
256
|
-
`SELECT ${fields} FROM OpportunityLineItem WHERE ${teamScope} AND Opportunity.IsClosed = false AND ${inPlayDateFilter} AND ${commonFilters}`,
|
|
257
|
-
orgAlias,
|
|
258
|
-
),
|
|
259
|
-
runSfQuery(
|
|
260
|
-
`SELECT ${fields} FROM OpportunityLineItem WHERE ${teamScope} AND Opportunity.IsWon = true AND ${quarterDateFilter} AND ${commonFilters}`,
|
|
261
|
-
orgAlias,
|
|
262
|
-
),
|
|
263
|
-
]);
|
|
264
|
-
|
|
265
|
-
// Parse line items
|
|
266
|
-
const netNewItems = netNewRecords.map(r => parseLineItem(r, skuClassification));
|
|
267
|
-
const bookedItems = bookedRecords.map(r => parseLineItem(r, skuClassification));
|
|
468
|
+
// Combined OLI query: net new (open, in-play dates) + booked (won, quarter dates) in one SOQL.
|
|
469
|
+
// The OR between regular conditions is allowed (SOQL only restricts OR with semi-join subselects).
|
|
470
|
+
const combinedDateFilter = `((Opportunity.IsClosed = false AND ${inPlayDateFilter}) OR (Opportunity.IsWon = true AND ${quarterDateFilter}))`;
|
|
268
471
|
|
|
269
|
-
//
|
|
472
|
+
// Build renewals query alongside OLI query (no dependency between them)
|
|
270
473
|
const renewalFields = [
|
|
271
474
|
"Id",
|
|
272
475
|
"Account.Name",
|
|
@@ -277,17 +480,133 @@ export async function generatePipelineReport(options: PipelineReportOptions): Pr
|
|
|
277
480
|
"Use_Case_Category__c",
|
|
278
481
|
"ForecastCategoryName",
|
|
279
482
|
"Territory_Credited_District_del__c",
|
|
483
|
+
"CloseDate",
|
|
484
|
+
"Name",
|
|
280
485
|
].join(", ");
|
|
281
|
-
|
|
282
486
|
const renewalDateFilter = staleCutoff
|
|
283
487
|
? `CloseDate >= ${staleCutoff}`
|
|
284
488
|
: `CloseDate >= ${quarterStart} AND CloseDate <= ${quarterEnd}`;
|
|
285
|
-
const renewalWhere = `
|
|
489
|
+
const renewalWhere = `Id IN (SELECT OpportunityId FROM OpportunityTeamMember WHERE ${userFilter}) AND IsClosed = false AND Renewal__c = true AND ${renewalDateFilter} AND ForecastCategoryName != 'Omitted' AND (True_ACV__c > 1 OR Upsell_ACV__c > 1 OR Amount > 1)`;
|
|
490
|
+
// F5 fiscal year starts November 1. Derive from the report's quarter dates, not wall-clock,
|
|
491
|
+
// so the FY context matches the requested report period.
|
|
492
|
+
const qStartDate = new Date(quarterStart + "T00:00:00");
|
|
493
|
+
const qMonth = qStartDate.getMonth();
|
|
494
|
+
const qYear = qStartDate.getFullYear();
|
|
495
|
+
const fyStartYear = qMonth >= 10 ? qYear : qYear - 1;
|
|
496
|
+
const fyStart = `${fyStartYear}-11-01`;
|
|
497
|
+
const fyLabel = `FY${(fyStartYear + 1) % 100}`;
|
|
498
|
+
const todayStr = new Date().toISOString().split("T")[0]!;
|
|
499
|
+
const oppTeamScope = `Id IN (SELECT OpportunityId FROM OpportunityTeamMember WHERE ${userFilter})`;
|
|
500
|
+
const fyBookedQuery = `SELECT SUM(Amount) total FROM Opportunity WHERE ${oppTeamScope} AND IsWon = true AND CloseDate >= ${fyStart} AND CloseDate <= ${todayStr}`;
|
|
501
|
+
|
|
502
|
+
// Three parallel queries first: combined OLI + renewals + FY booked
|
|
503
|
+
const [oliRecords, renewalRecords, fyBookedResult] = await Promise.all([
|
|
504
|
+
runSfQuery(
|
|
505
|
+
`SELECT ${fields} FROM OpportunityLineItem WHERE ${oliTeamScope} AND ${combinedDateFilter} AND ${commonFilters}`,
|
|
506
|
+
orgAlias,
|
|
507
|
+
),
|
|
508
|
+
runSfQuery(
|
|
509
|
+
`SELECT ${renewalFields} FROM Opportunity WHERE ${renewalWhere} ORDER BY True_ACV__c DESC NULLS LAST`,
|
|
510
|
+
orgAlias,
|
|
511
|
+
),
|
|
512
|
+
runSfQuery(fyBookedQuery, orgAlias),
|
|
513
|
+
]);
|
|
514
|
+
const fyBookedTotal = (fyBookedResult[0]?.total as number) ?? 0;
|
|
515
|
+
|
|
516
|
+
// --- Split combined OLI records + fallback when empty ---
|
|
517
|
+
let netNewItems: LineItemRecord[];
|
|
518
|
+
let bookedItems: LineItemRecord[];
|
|
519
|
+
|
|
520
|
+
if (oliRecords.length > 0) {
|
|
521
|
+
netNewItems = [];
|
|
522
|
+
bookedItems = [];
|
|
523
|
+
for (const r of oliRecords) {
|
|
524
|
+
const item = parseLineItem(r, skuClassification);
|
|
525
|
+
const opp = (r.Opportunity ?? {}) as Record<string, unknown>;
|
|
526
|
+
if (opp.IsWon as boolean) {
|
|
527
|
+
bookedItems.push(item);
|
|
528
|
+
} else if (!(opp.IsClosed as boolean)) {
|
|
529
|
+
netNewItems.push(item);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
} else {
|
|
533
|
+
// Opportunity-level fallback when OLI queries return no data.
|
|
534
|
+
// Collects renewal IDs first so we can exclude them — renewals have their own query
|
|
535
|
+
// and would double-count if included in net new/booked.
|
|
536
|
+
const renewalIds = new Set<string>();
|
|
537
|
+
for (const r of renewalRecords) {
|
|
538
|
+
const id = (r.Id ?? "") as string;
|
|
539
|
+
if (id) renewalIds.add(id);
|
|
540
|
+
}
|
|
541
|
+
const oppFields =
|
|
542
|
+
"Id, Name, Account.Name, Amount, ForecastCategoryName, StageName, CloseDate, LastActivityDate, Owner.Name, IsClosed, IsWon";
|
|
543
|
+
const oppTeamScope = `Id IN (SELECT OpportunityId FROM OpportunityTeamMember WHERE ${userFilter})`;
|
|
544
|
+
const oppDateFilter = staleCutoff
|
|
545
|
+
? `CloseDate >= ${staleCutoff}`
|
|
546
|
+
: `CloseDate >= ${quarterStart} AND CloseDate <= ${quarterEnd}`;
|
|
547
|
+
const oppWhere = `${oppTeamScope} AND ForecastCategoryName != 'Omitted' AND Amount > 0 AND IsClosed = false AND ${oppDateFilter}`;
|
|
548
|
+
const bookedWhere = `${oppTeamScope} AND ForecastCategoryName != 'Omitted' AND Amount > 0 AND IsWon = true AND CloseDate >= ${quarterStart} AND CloseDate <= ${quarterEnd}`;
|
|
549
|
+
|
|
550
|
+
const [fallbackNetNew, fallbackBooked] = await Promise.all([
|
|
551
|
+
runSfQuery(`SELECT ${oppFields} FROM Opportunity WHERE ${oppWhere} ORDER BY Amount DESC NULLS LAST`, orgAlias),
|
|
552
|
+
runSfQuery(
|
|
553
|
+
`SELECT ${oppFields} FROM Opportunity WHERE ${bookedWhere} ORDER BY Amount DESC NULLS LAST`,
|
|
554
|
+
orgAlias,
|
|
555
|
+
),
|
|
556
|
+
]);
|
|
557
|
+
|
|
558
|
+
function parseOppFallback(records: Record<string, unknown>[]): LineItemRecord[] {
|
|
559
|
+
const items: LineItemRecord[] = [];
|
|
560
|
+
const seen = new Set<string>();
|
|
561
|
+
for (const r of records) {
|
|
562
|
+
const id = (r.Id ?? "") as string;
|
|
563
|
+
if (seen.has(id) || renewalIds.has(id)) continue;
|
|
564
|
+
seen.add(id);
|
|
565
|
+
const acct = ((r.Account as Record<string, unknown> | undefined)?.Name ?? "") as string;
|
|
566
|
+
const fc = (r.ForecastCategoryName ?? "") as string;
|
|
567
|
+
const amt = (r.Amount as number) ?? 0;
|
|
568
|
+
if (amt <= 0) continue;
|
|
569
|
+
const owner = (r.Owner as Record<string, unknown> | undefined)?.Name;
|
|
570
|
+
items.push({
|
|
571
|
+
opportunityId: id,
|
|
572
|
+
accountName: acct,
|
|
573
|
+
territory: "",
|
|
574
|
+
forecast: fc,
|
|
575
|
+
skuName: "Opportunity-level",
|
|
576
|
+
fyb: amt,
|
|
577
|
+
category: "platform",
|
|
578
|
+
closeDate: (r.CloseDate as string) ?? undefined,
|
|
579
|
+
oppName: (r.Name as string) ?? undefined,
|
|
580
|
+
stage: (r.StageName as string) ?? undefined,
|
|
581
|
+
lastActivityDate: (r.LastActivityDate as string) ?? undefined,
|
|
582
|
+
ownerName: (owner as string) ?? undefined,
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
return items;
|
|
586
|
+
}
|
|
286
587
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
588
|
+
netNewItems = parseOppFallback(fallbackNetNew);
|
|
589
|
+
bookedItems = parseOppFallback(fallbackBooked);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// History query: run AFTER OLI to use specific opp IDs (avoids slow semi-join on FieldHistory).
|
|
593
|
+
// Only runs in the normal OLI path — in the fallback path we've already spent 5 queries
|
|
594
|
+
// (3 parallel + 2 fallback) and would exceed the 5-query constraint.
|
|
595
|
+
const allOppIds = new Set<string>();
|
|
596
|
+
for (const item of [...netNewItems, ...bookedItems]) {
|
|
597
|
+
if (item.opportunityId) allOppIds.add(item.opportunityId);
|
|
598
|
+
}
|
|
599
|
+
let historyRecords: Record<string, unknown>[] = [];
|
|
600
|
+
if (oliRecords.length > 0 && allOppIds.size > 0) {
|
|
601
|
+
const idList = [...allOppIds].map(id => `'${id}'`).join(",");
|
|
602
|
+
const historyFields =
|
|
603
|
+
"OpportunityId, Opportunity.Name, Opportunity.Account.Name, Field, OldValue, NewValue, CreatedDate";
|
|
604
|
+
const historyWhere = `OpportunityId IN (${idList}) AND Field IN ('Amount','ForecastCategoryName','StageName') AND CreatedDate = LAST_N_DAYS:7`;
|
|
605
|
+
historyRecords = await runSfQuery(
|
|
606
|
+
`SELECT ${historyFields} FROM OpportunityFieldHistory WHERE ${historyWhere} ORDER BY CreatedDate DESC LIMIT 50`,
|
|
607
|
+
orgAlias,
|
|
608
|
+
);
|
|
609
|
+
}
|
|
291
610
|
|
|
292
611
|
// Parse and classify renewal opps, dedup by Id
|
|
293
612
|
const seenIds = new Set<string>();
|
|
@@ -326,6 +645,8 @@ export async function generatePipelineReport(options: PipelineReportOptions): Pr
|
|
|
326
645
|
skuName: (r.Product_Segmentation__c ?? "") as string,
|
|
327
646
|
fyb: val,
|
|
328
647
|
category,
|
|
648
|
+
closeDate: (r.CloseDate as string) ?? undefined,
|
|
649
|
+
oppName: (r.Name as string) ?? undefined,
|
|
329
650
|
});
|
|
330
651
|
}
|
|
331
652
|
|
|
@@ -335,7 +656,7 @@ export async function generatePipelineReport(options: PipelineReportOptions): Pr
|
|
|
335
656
|
if (item.skuName) allSkus.add(item.skuName);
|
|
336
657
|
}
|
|
337
658
|
// Detect anomalies
|
|
338
|
-
const anomalies = detectAnomalies(netNewItems, bookedItems, skuClassification);
|
|
659
|
+
const anomalies = detectAnomalies(netNewItems, bookedItems, renewalItems, skuClassification);
|
|
339
660
|
|
|
340
661
|
return {
|
|
341
662
|
generated: new Date().toISOString(),
|
|
@@ -349,5 +670,12 @@ export async function generatePipelineReport(options: PipelineReportOptions): Pr
|
|
|
349
670
|
lineItemCount: netNewItems.length + bookedItems.length + renewalItems.length,
|
|
350
671
|
skusFound: [...allSkus].sort(),
|
|
351
672
|
anomalies,
|
|
673
|
+
topDeals: buildTopDeals(netNewItems),
|
|
674
|
+
topRenewals: renewalItems.length > 0 ? buildTopDeals(renewalItems) : undefined,
|
|
675
|
+
closeDistribution: buildCloseDistribution(netNewItems),
|
|
676
|
+
renewalDistribution: renewalItems.length > 0 ? buildCloseDistribution(renewalItems) : undefined,
|
|
677
|
+
fyBookedTotal: fyBookedTotal > 0 ? fyBookedTotal : undefined,
|
|
678
|
+
fyLabel,
|
|
679
|
+
recentChanges: parseHistoryRecords(historyRecords),
|
|
352
680
|
};
|
|
353
681
|
}
|