@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5xc-salesdemos/xcsh",
4
- "version": "18.69.0",
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.69.0",
52
- "@f5xc-salesdemos/pi-agent-core": "18.69.0",
53
- "@f5xc-salesdemos/pi-ai": "18.69.0",
54
- "@f5xc-salesdemos/pi-natives": "18.69.0",
55
- "@f5xc-salesdemos/pi-tui": "18.69.0",
56
- "@f5xc-salesdemos/pi-utils": "18.69.0",
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.69.0",
21
- "commit": "309ca5b1b28a5530f01b83381e69f8a6c94f83c8",
22
- "shortCommit": "309ca5b",
20
+ "version": "18.72.0",
21
+ "commit": "ee865e583dfa21db5c5f5bf07001570c5f7f9086",
22
+ "shortCommit": "ee865e5",
23
23
  "branch": "main",
24
- "tag": "v18.69.0",
25
- "commitDate": "2026-05-18T23:00:56Z",
26
- "buildDate": "2026-05-18T23:27:04.429Z",
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/309ca5b1b28a5530f01b83381e69f8a6c94f83c8",
32
- "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.69.0"
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
- const ownerFilter =
235
- userIds.length === 1 ? `OwnerId = '${userIds[0]}'` : `OwnerId IN (${userIds.map(id => `'${id}'`).join(",")})`;
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 teamScope = `(OpportunityId IN (SELECT OpportunityId FROM OpportunityTeamMember WHERE ${userFilter}) OR ${ownerFilter})`;
466
+ const oliTeamScope = `OpportunityId IN (SELECT OpportunityId FROM OpportunityTeamMember WHERE ${userFilter})`;
251
467
 
252
- // In-play: uses staleCutoff if set, otherwise quarter dates
253
- // Booked: always quarter dates
254
- const [netNewRecords, bookedRecords] = await Promise.all([
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
- // --- Renewals: opportunity-level True_ACV (FYB returns $0 for renewals) ---
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 = `(Id IN (SELECT OpportunityId FROM OpportunityTeamMember WHERE ${userFilter}) OR ${ownerFilter}) 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)`;
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
- const renewalRecords = await runSfQuery(
288
- `SELECT ${renewalFields} FROM Opportunity WHERE ${renewalWhere} ORDER BY True_ACV__c DESC NULLS LAST`,
289
- orgAlias,
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
  }