optimal-cli 1.0.0 → 1.0.1

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 (135) hide show
  1. package/dist/bin/optimal.d.ts +2 -0
  2. package/dist/bin/optimal.js +1590 -0
  3. package/dist/lib/assets/index.d.ts +79 -0
  4. package/dist/lib/assets/index.js +153 -0
  5. package/dist/lib/assets.d.ts +20 -0
  6. package/dist/lib/assets.js +112 -0
  7. package/dist/lib/auth/index.d.ts +83 -0
  8. package/dist/lib/auth/index.js +146 -0
  9. package/dist/lib/board/index.d.ts +39 -0
  10. package/dist/lib/board/index.js +285 -0
  11. package/dist/lib/board/types.d.ts +111 -0
  12. package/dist/lib/board/types.js +1 -0
  13. package/dist/lib/bot/claim.d.ts +3 -0
  14. package/dist/lib/bot/claim.js +20 -0
  15. package/dist/lib/bot/coordinator.d.ts +27 -0
  16. package/dist/lib/bot/coordinator.js +178 -0
  17. package/dist/lib/bot/heartbeat.d.ts +6 -0
  18. package/dist/lib/bot/heartbeat.js +30 -0
  19. package/dist/lib/bot/index.d.ts +9 -0
  20. package/dist/lib/bot/index.js +6 -0
  21. package/dist/lib/bot/protocol.d.ts +12 -0
  22. package/dist/lib/bot/protocol.js +74 -0
  23. package/dist/lib/bot/reporter.d.ts +3 -0
  24. package/dist/lib/bot/reporter.js +27 -0
  25. package/dist/lib/bot/skills.d.ts +26 -0
  26. package/dist/lib/bot/skills.js +69 -0
  27. package/dist/lib/budget/projections.d.ts +115 -0
  28. package/dist/lib/budget/projections.js +384 -0
  29. package/dist/lib/budget/scenarios.d.ts +93 -0
  30. package/dist/lib/budget/scenarios.js +214 -0
  31. package/dist/lib/cms/publish-blog.d.ts +62 -0
  32. package/dist/lib/cms/publish-blog.js +74 -0
  33. package/dist/lib/cms/strapi-client.d.ts +123 -0
  34. package/dist/lib/cms/strapi-client.js +213 -0
  35. package/dist/lib/config/registry.d.ts +17 -0
  36. package/dist/lib/config/registry.js +182 -0
  37. package/dist/lib/config/schema.d.ts +31 -0
  38. package/dist/lib/config/schema.js +25 -0
  39. package/dist/lib/config.d.ts +55 -0
  40. package/dist/lib/config.js +206 -0
  41. package/dist/lib/errors.d.ts +25 -0
  42. package/dist/lib/errors.js +91 -0
  43. package/dist/lib/format.d.ts +28 -0
  44. package/dist/lib/format.js +98 -0
  45. package/dist/lib/infra/deploy.d.ts +29 -0
  46. package/dist/lib/infra/deploy.js +58 -0
  47. package/dist/lib/infra/migrate.d.ts +34 -0
  48. package/dist/lib/infra/migrate.js +103 -0
  49. package/dist/lib/newsletter/distribute.d.ts +52 -0
  50. package/dist/lib/newsletter/distribute.js +193 -0
  51. package/{lib/newsletter/generate-insurance.ts → dist/lib/newsletter/generate-insurance.d.ts} +7 -24
  52. package/dist/lib/newsletter/generate-insurance.js +36 -0
  53. package/dist/lib/newsletter/generate.d.ts +104 -0
  54. package/dist/lib/newsletter/generate.js +571 -0
  55. package/dist/lib/returnpro/anomalies.d.ts +64 -0
  56. package/dist/lib/returnpro/anomalies.js +166 -0
  57. package/dist/lib/returnpro/audit.d.ts +32 -0
  58. package/dist/lib/returnpro/audit.js +147 -0
  59. package/dist/lib/returnpro/diagnose.d.ts +52 -0
  60. package/dist/lib/returnpro/diagnose.js +281 -0
  61. package/dist/lib/returnpro/kpis.d.ts +32 -0
  62. package/dist/lib/returnpro/kpis.js +192 -0
  63. package/dist/lib/returnpro/templates.d.ts +48 -0
  64. package/dist/lib/returnpro/templates.js +229 -0
  65. package/dist/lib/returnpro/upload-income.d.ts +25 -0
  66. package/dist/lib/returnpro/upload-income.js +235 -0
  67. package/dist/lib/returnpro/upload-netsuite.d.ts +37 -0
  68. package/dist/lib/returnpro/upload-netsuite.js +566 -0
  69. package/dist/lib/returnpro/upload-r1.d.ts +48 -0
  70. package/dist/lib/returnpro/upload-r1.js +398 -0
  71. package/dist/lib/returnpro/validate.d.ts +37 -0
  72. package/dist/lib/returnpro/validate.js +124 -0
  73. package/dist/lib/social/meta.d.ts +90 -0
  74. package/dist/lib/social/meta.js +160 -0
  75. package/dist/lib/social/post-generator.d.ts +83 -0
  76. package/dist/lib/social/post-generator.js +333 -0
  77. package/dist/lib/social/publish.d.ts +66 -0
  78. package/dist/lib/social/publish.js +226 -0
  79. package/dist/lib/social/scraper.d.ts +67 -0
  80. package/dist/lib/social/scraper.js +361 -0
  81. package/dist/lib/supabase.d.ts +4 -0
  82. package/dist/lib/supabase.js +20 -0
  83. package/dist/lib/transactions/delete-batch.d.ts +60 -0
  84. package/dist/lib/transactions/delete-batch.js +203 -0
  85. package/dist/lib/transactions/ingest.d.ts +43 -0
  86. package/dist/lib/transactions/ingest.js +555 -0
  87. package/dist/lib/transactions/stamp.d.ts +51 -0
  88. package/dist/lib/transactions/stamp.js +524 -0
  89. package/package.json +3 -4
  90. package/bin/optimal.ts +0 -1731
  91. package/lib/assets/index.ts +0 -225
  92. package/lib/assets.ts +0 -124
  93. package/lib/auth/index.ts +0 -189
  94. package/lib/board/index.ts +0 -309
  95. package/lib/board/types.ts +0 -124
  96. package/lib/bot/claim.ts +0 -43
  97. package/lib/bot/coordinator.ts +0 -254
  98. package/lib/bot/heartbeat.ts +0 -37
  99. package/lib/bot/index.ts +0 -9
  100. package/lib/bot/protocol.ts +0 -99
  101. package/lib/bot/reporter.ts +0 -42
  102. package/lib/bot/skills.ts +0 -81
  103. package/lib/budget/projections.ts +0 -561
  104. package/lib/budget/scenarios.ts +0 -312
  105. package/lib/cms/publish-blog.ts +0 -129
  106. package/lib/cms/strapi-client.ts +0 -302
  107. package/lib/config/registry.ts +0 -228
  108. package/lib/config/schema.ts +0 -58
  109. package/lib/config.ts +0 -247
  110. package/lib/errors.ts +0 -129
  111. package/lib/format.ts +0 -120
  112. package/lib/infra/.gitkeep +0 -0
  113. package/lib/infra/deploy.ts +0 -70
  114. package/lib/infra/migrate.ts +0 -141
  115. package/lib/newsletter/.gitkeep +0 -0
  116. package/lib/newsletter/distribute.ts +0 -256
  117. package/lib/newsletter/generate.ts +0 -735
  118. package/lib/returnpro/.gitkeep +0 -0
  119. package/lib/returnpro/anomalies.ts +0 -258
  120. package/lib/returnpro/audit.ts +0 -194
  121. package/lib/returnpro/diagnose.ts +0 -400
  122. package/lib/returnpro/kpis.ts +0 -255
  123. package/lib/returnpro/templates.ts +0 -323
  124. package/lib/returnpro/upload-income.ts +0 -311
  125. package/lib/returnpro/upload-netsuite.ts +0 -696
  126. package/lib/returnpro/upload-r1.ts +0 -563
  127. package/lib/returnpro/validate.ts +0 -154
  128. package/lib/social/meta.ts +0 -228
  129. package/lib/social/post-generator.ts +0 -468
  130. package/lib/social/publish.ts +0 -301
  131. package/lib/social/scraper.ts +0 -503
  132. package/lib/supabase.ts +0 -25
  133. package/lib/transactions/delete-batch.ts +0 -258
  134. package/lib/transactions/ingest.ts +0 -659
  135. package/lib/transactions/stamp.ts +0 -654
@@ -0,0 +1,384 @@
1
+ /**
2
+ * Budget projection calculator for FY26 planning
3
+ *
4
+ * Ported from wes-dashboard/src/lib/projections/calculator.ts
5
+ * Pure TypeScript — no React, no framework deps.
6
+ *
7
+ * Supports two adjustment types:
8
+ * - Percentage: projected = actual * (1 + rate/100)
9
+ * - Flat: projected = actual + flatAmount
10
+ *
11
+ * Supports both unit AND average retail projections for revenue forecasting.
12
+ *
13
+ * Data sources:
14
+ * - Supabase `fpa_wes_imports` table (ReturnPro instance)
15
+ * - JSON file from stdin or --file flag
16
+ */
17
+ import { getSupabase } from '../supabase.js';
18
+ // --- Core calculation functions ---
19
+ /**
20
+ * Calculate projected units based on adjustment type and value.
21
+ */
22
+ export function calculateProjection(input) {
23
+ const { actualUnits, adjustmentType, adjustmentValue } = input;
24
+ if (adjustmentType === 'percentage') {
25
+ return Math.round(actualUnits * (1 + adjustmentValue / 100));
26
+ }
27
+ else {
28
+ return Math.max(0, actualUnits + adjustmentValue);
29
+ }
30
+ }
31
+ /**
32
+ * Calculate projected average retail price.
33
+ */
34
+ export function calculateAvgRetailProjection(actualAvgRetail, adjustmentType, adjustmentValue) {
35
+ if (actualAvgRetail == null)
36
+ return undefined;
37
+ if (adjustmentType === 'percentage') {
38
+ return actualAvgRetail * (1 + adjustmentValue / 100);
39
+ }
40
+ else {
41
+ return Math.max(0, actualAvgRetail + adjustmentValue);
42
+ }
43
+ }
44
+ /**
45
+ * Convert checked-in units summary to projection entries with default values (0% change).
46
+ */
47
+ export function initializeProjections(summary) {
48
+ return (summary ?? []).map((item) => {
49
+ const units = typeof item.unitCount === 'number' ? item.unitCount : 0;
50
+ const retail = typeof item.avgRetail === 'number' ? item.avgRetail : undefined;
51
+ return {
52
+ programCode: item.programCode ?? '',
53
+ masterProgram: item.masterProgram ?? '',
54
+ masterProgramId: item.masterProgramId ?? null,
55
+ clientId: item.clientId ?? null,
56
+ clientName: item.clientName ?? 'Unknown',
57
+ actualUnits: units,
58
+ adjustmentType: 'percentage',
59
+ adjustmentValue: 0,
60
+ projectedUnits: units,
61
+ avgRetail: retail,
62
+ avgRetailAdjustmentType: 'percentage',
63
+ avgRetailAdjustmentValue: 0,
64
+ projectedAvgRetail: retail,
65
+ };
66
+ });
67
+ }
68
+ /**
69
+ * Update a single projection entry's units.
70
+ */
71
+ export function updateProjection(entry, adjustmentType, adjustmentValue) {
72
+ const projectedUnits = calculateProjection({
73
+ actualUnits: entry.actualUnits,
74
+ adjustmentType,
75
+ adjustmentValue,
76
+ });
77
+ return { ...entry, adjustmentType, adjustmentValue, projectedUnits };
78
+ }
79
+ /**
80
+ * Update a single projection entry's average retail.
81
+ */
82
+ export function updateAvgRetailProjection(entry, adjustmentType, adjustmentValue) {
83
+ const projectedAvgRetail = calculateAvgRetailProjection(entry.avgRetail, adjustmentType, adjustmentValue);
84
+ return {
85
+ ...entry,
86
+ avgRetailAdjustmentType: adjustmentType,
87
+ avgRetailAdjustmentValue: adjustmentValue,
88
+ projectedAvgRetail,
89
+ };
90
+ }
91
+ /**
92
+ * Apply a uniform unit adjustment to all projections.
93
+ */
94
+ export function applyUniformAdjustment(projections, adjustmentType, adjustmentValue) {
95
+ return projections.map((entry) => updateProjection(entry, adjustmentType, adjustmentValue));
96
+ }
97
+ /**
98
+ * Apply a uniform avg retail adjustment to all projections.
99
+ */
100
+ export function applyUniformAvgRetailAdjustment(projections, adjustmentType, adjustmentValue) {
101
+ return projections.map((entry) => updateAvgRetailProjection(entry, adjustmentType, adjustmentValue));
102
+ }
103
+ /**
104
+ * Calculate totals for projection summary including revenue.
105
+ */
106
+ export function calculateTotals(projections) {
107
+ const totalActual = projections.reduce((sum, p) => sum + p.actualUnits, 0);
108
+ const totalProjected = projections.reduce((sum, p) => sum + p.projectedUnits, 0);
109
+ const absoluteChange = totalProjected - totalActual;
110
+ const percentageChange = totalActual > 0
111
+ ? ((totalProjected - totalActual) / totalActual) * 100
112
+ : 0;
113
+ const actualRevenue = projections.reduce((sum, p) => {
114
+ if (p.avgRetail != null)
115
+ return sum + p.actualUnits * p.avgRetail;
116
+ return sum;
117
+ }, 0);
118
+ const projectedRevenue = projections.reduce((sum, p) => {
119
+ if (p.projectedAvgRetail != null)
120
+ return sum + p.projectedUnits * p.projectedAvgRetail;
121
+ return sum;
122
+ }, 0);
123
+ const revenueChange = projectedRevenue - actualRevenue;
124
+ const revenuePercentChange = actualRevenue > 0
125
+ ? ((projectedRevenue - actualRevenue) / actualRevenue) * 100
126
+ : 0;
127
+ return {
128
+ totalActual,
129
+ totalProjected,
130
+ percentageChange,
131
+ absoluteChange,
132
+ actualRevenue,
133
+ projectedRevenue,
134
+ revenueChange,
135
+ revenuePercentChange,
136
+ };
137
+ }
138
+ /**
139
+ * Group projections by client name.
140
+ */
141
+ export function groupProjectionsByClient(projections) {
142
+ const groups = new Map();
143
+ for (const p of projections) {
144
+ const list = groups.get(p.clientName) ?? [];
145
+ list.push(p);
146
+ groups.set(p.clientName, list);
147
+ }
148
+ return groups;
149
+ }
150
+ /**
151
+ * Export projections to CSV format with unit + avgRetail + inventory value data.
152
+ */
153
+ export function exportToCSV(projections) {
154
+ const headers = [
155
+ 'Program Code',
156
+ 'Master Program',
157
+ 'Client',
158
+ '2025 Actual Units',
159
+ 'Unit Adj Type',
160
+ 'Unit Adj Value',
161
+ '2026 Projected Units',
162
+ 'Unit Change',
163
+ 'Unit Change %',
164
+ '2025 Avg Retail',
165
+ 'Retail Adj Type',
166
+ 'Retail Adj Value',
167
+ '2026 Projected Retail',
168
+ 'Retail Change',
169
+ 'Retail Change %',
170
+ '2025 Inventory Value',
171
+ '2026 Projected Inv. Value',
172
+ 'Inv. Value Change',
173
+ 'Inv. Value Change %',
174
+ ];
175
+ const rows = projections.map((p) => {
176
+ const unitChange = p.projectedUnits - p.actualUnits;
177
+ const unitChangePct = p.actualUnits > 0
178
+ ? ((unitChange / p.actualUnits) * 100).toFixed(1)
179
+ : '0.0';
180
+ const retailChange = (p.projectedAvgRetail ?? 0) - (p.avgRetail ?? 0);
181
+ const retailChangePct = p.avgRetail != null && p.avgRetail > 0
182
+ ? ((retailChange / p.avgRetail) * 100).toFixed(1)
183
+ : '0.0';
184
+ const actualRev = p.avgRetail != null ? p.actualUnits * p.avgRetail : 0;
185
+ const projRev = p.projectedAvgRetail != null
186
+ ? p.projectedUnits * p.projectedAvgRetail
187
+ : 0;
188
+ const revChange = projRev - actualRev;
189
+ const revChangePct = actualRev > 0 ? ((revChange / actualRev) * 100).toFixed(1) : '0.0';
190
+ return [
191
+ csvEscape(p.programCode),
192
+ csvEscape(p.masterProgram),
193
+ csvEscape(p.clientName),
194
+ p.actualUnits,
195
+ p.adjustmentType,
196
+ p.adjustmentType === 'percentage'
197
+ ? `${p.adjustmentValue}%`
198
+ : p.adjustmentValue,
199
+ p.projectedUnits,
200
+ unitChange,
201
+ `${unitChangePct}%`,
202
+ p.avgRetail != null ? `$${p.avgRetail.toFixed(2)}` : '',
203
+ p.avgRetailAdjustmentType,
204
+ p.avgRetailAdjustmentType === 'percentage'
205
+ ? `${p.avgRetailAdjustmentValue}%`
206
+ : `$${p.avgRetailAdjustmentValue}`,
207
+ p.projectedAvgRetail != null
208
+ ? `$${p.projectedAvgRetail.toFixed(2)}`
209
+ : '',
210
+ p.avgRetail != null ? `$${retailChange.toFixed(2)}` : '',
211
+ p.avgRetail != null ? `${retailChangePct}%` : '',
212
+ actualRev > 0 ? `$${actualRev.toFixed(2)}` : '',
213
+ projRev > 0 ? `$${projRev.toFixed(2)}` : '',
214
+ actualRev > 0 ? `$${revChange.toFixed(2)}` : '',
215
+ actualRev > 0 ? `${revChangePct}%` : '',
216
+ ].join(',');
217
+ });
218
+ return [headers.join(','), ...rows].join('\n');
219
+ }
220
+ // --- Data fetching ---
221
+ const PAGE_SIZE = 1000;
222
+ /**
223
+ * Fetch FY25 actuals from fpa_wes_imports on the ReturnPro Supabase instance.
224
+ * Aggregates across all months for a given fiscal year and user,
225
+ * returning one CheckedInUnitsSummary per master program.
226
+ */
227
+ export async function fetchWesImports(options) {
228
+ const sb = getSupabase('returnpro');
229
+ const fy = options?.fiscalYear ?? 2025;
230
+ const summaryMap = new Map();
231
+ let from = 0;
232
+ while (true) {
233
+ let query = sb
234
+ .from('fpa_wes_imports')
235
+ .select(`
236
+ program_code,
237
+ master_program_id,
238
+ actual_units_prior_year,
239
+ projected_units,
240
+ avg_retail_prior_year,
241
+ projected_avg_retail,
242
+ unit_adj_type,
243
+ unit_adj_value,
244
+ retail_adj_type,
245
+ retail_adj_value,
246
+ dim_master_program(master_name, client_id, dim_client(client_name))
247
+ `)
248
+ .eq('fiscal_year', fy)
249
+ .order('master_program_id')
250
+ .range(from, from + PAGE_SIZE - 1);
251
+ if (options?.userId) {
252
+ query = query.eq('user_id', options.userId);
253
+ }
254
+ const { data, error } = await query;
255
+ if (error)
256
+ throw new Error(`Fetch fpa_wes_imports failed: ${error.message}`);
257
+ if (!data || data.length === 0)
258
+ break;
259
+ for (const row of data) {
260
+ const mpId = row.master_program_id;
261
+ const existing = summaryMap.get(mpId);
262
+ const units = row.actual_units_prior_year ?? row.projected_units ?? 0;
263
+ const retail = row.avg_retail_prior_year ?? null;
264
+ if (existing) {
265
+ existing.totalUnits += units;
266
+ if (retail != null) {
267
+ existing.retailSum += retail;
268
+ existing.retailCount += 1;
269
+ }
270
+ }
271
+ else {
272
+ const dim = row.dim_master_program;
273
+ summaryMap.set(mpId, {
274
+ programCode: row.program_code ?? '',
275
+ masterProgram: dim?.master_name ?? '',
276
+ masterProgramId: mpId,
277
+ clientId: dim?.client_id ?? null,
278
+ clientName: dim?.dim_client?.client_name ?? 'Unknown',
279
+ totalUnits: units,
280
+ retailSum: retail ?? 0,
281
+ retailCount: retail != null ? 1 : 0,
282
+ });
283
+ }
284
+ }
285
+ if (data.length < PAGE_SIZE)
286
+ break;
287
+ from += PAGE_SIZE;
288
+ }
289
+ const results = [];
290
+ for (const entry of summaryMap.values()) {
291
+ results.push({
292
+ programCode: entry.programCode,
293
+ masterProgram: entry.masterProgram,
294
+ masterProgramId: entry.masterProgramId,
295
+ clientId: entry.clientId,
296
+ clientName: entry.clientName,
297
+ unitCount: entry.totalUnits,
298
+ countMethod: 'Unit',
299
+ avgRetail: entry.retailCount > 0
300
+ ? entry.retailSum / entry.retailCount
301
+ : undefined,
302
+ });
303
+ }
304
+ results.sort((a, b) => a.clientName.localeCompare(b.clientName) || a.masterProgram.localeCompare(b.masterProgram));
305
+ return results;
306
+ }
307
+ /**
308
+ * Parse a JSON file (array of CheckedInUnitsSummary) as an alternative data source.
309
+ * Accepts raw JSON string (e.g., from stdin or file read).
310
+ */
311
+ export function parseSummaryFromJson(json) {
312
+ const data = JSON.parse(json);
313
+ if (!Array.isArray(data)) {
314
+ throw new Error('Expected a JSON array of CheckedInUnitsSummary objects');
315
+ }
316
+ return data;
317
+ }
318
+ // --- Formatting helpers ---
319
+ function csvEscape(s) {
320
+ if (s.includes(',') || s.includes('"') || s.includes('\n')) {
321
+ return `"${s.replace(/"/g, '""')}"`;
322
+ }
323
+ return s;
324
+ }
325
+ function fmtCompact(n) {
326
+ const abs = Math.abs(n);
327
+ const sign = n < 0 ? '-' : '';
328
+ if (abs >= 1_000_000)
329
+ return `${sign}$${(abs / 1_000_000).toFixed(1)}M`;
330
+ if (abs >= 1_000)
331
+ return `${sign}$${(abs / 1_000).toFixed(1)}K`;
332
+ return `${sign}$${abs.toFixed(0)}`;
333
+ }
334
+ function fmtUnits(n) {
335
+ if (Math.abs(n) >= 1_000_000)
336
+ return `${(n / 1_000_000).toFixed(2)}M`;
337
+ if (Math.abs(n) >= 1_000)
338
+ return `${(n / 1_000).toFixed(1)}K`;
339
+ return String(n);
340
+ }
341
+ function fmtDelta(pct) {
342
+ const arrow = pct >= 0 ? '\u2191' : '\u2193';
343
+ return `${arrow}${pct >= 0 ? '+' : ''}${pct.toFixed(1)}%`;
344
+ }
345
+ /**
346
+ * Format projections as a Bloomberg-dense markdown table.
347
+ */
348
+ export function formatProjectionTable(projections) {
349
+ if (projections.length === 0)
350
+ return 'No projection data.';
351
+ const totals = calculateTotals(projections);
352
+ const lines = [];
353
+ // Summary header
354
+ lines.push(`FY25 Actual: ${fmtUnits(totals.totalActual)} units | FY26 Projected: ${fmtUnits(totals.totalProjected)} units | ${fmtDelta(totals.percentageChange)}`);
355
+ if (totals.actualRevenue > 0) {
356
+ lines.push(`Revenue: ${fmtCompact(totals.actualRevenue)} -> ${fmtCompact(totals.projectedRevenue)} | ${fmtDelta(totals.revenuePercentChange)}`);
357
+ }
358
+ lines.push('');
359
+ // Table
360
+ lines.push('| Client | Program | FY25 Units | FY26 Units | Delta | Avg Retail | Proj Retail | Rev Delta |');
361
+ lines.push('|--------|---------|------------|------------|-------|------------|-------------|-----------|');
362
+ for (const p of projections) {
363
+ const unitDelta = p.projectedUnits - p.actualUnits;
364
+ const unitPct = p.actualUnits > 0
365
+ ? ((unitDelta / p.actualUnits) * 100).toFixed(1)
366
+ : '0.0';
367
+ const deltaStr = `${unitDelta >= 0 ? '+' : ''}${fmtUnits(unitDelta)} (${unitPct}%)`;
368
+ const retailStr = p.avgRetail != null ? `$${p.avgRetail.toFixed(2)}` : '-';
369
+ const projRetailStr = p.projectedAvgRetail != null
370
+ ? `$${p.projectedAvgRetail.toFixed(2)}`
371
+ : '-';
372
+ const actualRev = p.avgRetail != null ? p.actualUnits * p.avgRetail : 0;
373
+ const projRev = p.projectedAvgRetail != null
374
+ ? p.projectedUnits * p.projectedAvgRetail
375
+ : 0;
376
+ const revDelta = projRev - actualRev;
377
+ const revDeltaStr = actualRev > 0
378
+ ? `${revDelta >= 0 ? '+' : ''}${fmtCompact(revDelta)}`
379
+ : '-';
380
+ lines.push(`| ${p.clientName} | ${p.programCode} | ${fmtUnits(p.actualUnits)} | ${fmtUnits(p.projectedUnits)} | ${deltaStr} | ${retailStr} | ${projRetailStr} | ${revDeltaStr} |`);
381
+ }
382
+ lines.push(`\n${projections.length} programs`);
383
+ return lines.join('\n');
384
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Budget scenario manager — save, load, list, compare, and delete named scenarios.
3
+ *
4
+ * Scenarios are stored as JSON files on disk at:
5
+ * /home/optimal/optimal-cli/data/scenarios/{name}.json
6
+ *
7
+ * Each scenario captures a full snapshot of projected units after applying
8
+ * a uniform adjustment to the live fpa_wes_imports data.
9
+ */
10
+ import 'dotenv/config';
11
+ export interface SaveScenarioOptions {
12
+ name: string;
13
+ adjustmentType: 'percentage' | 'flat';
14
+ adjustmentValue: number;
15
+ fiscalYear?: number;
16
+ userId?: string;
17
+ description?: string;
18
+ }
19
+ export interface ScenarioData {
20
+ name: string;
21
+ createdAt: string;
22
+ adjustmentType: 'percentage' | 'flat';
23
+ adjustmentValue: number;
24
+ description?: string;
25
+ projections: Array<{
26
+ programCode: string;
27
+ masterProgram: string;
28
+ actualUnits: number;
29
+ projectedUnits: number;
30
+ }>;
31
+ totals: {
32
+ totalActual: number;
33
+ totalProjected: number;
34
+ percentageChange: number;
35
+ };
36
+ }
37
+ export interface ScenarioSummary {
38
+ name: string;
39
+ createdAt: string;
40
+ adjustmentType: string;
41
+ adjustmentValue: number;
42
+ description?: string;
43
+ totalProjected: number;
44
+ percentageChange: number;
45
+ }
46
+ export interface ComparisonResult {
47
+ scenarioNames: string[];
48
+ programs: Array<{
49
+ programCode: string;
50
+ masterProgram: string;
51
+ actual: number;
52
+ projectedByScenario: Record<string, number>;
53
+ }>;
54
+ totalsByScenario: Record<string, {
55
+ totalProjected: number;
56
+ percentageChange: number;
57
+ }>;
58
+ }
59
+ /**
60
+ * Save current projections as a named scenario to disk.
61
+ *
62
+ * Fetches live data via fetchWesImports, applies the given adjustment,
63
+ * calculates totals, and writes the result as JSON.
64
+ *
65
+ * @returns The absolute path to the saved scenario file.
66
+ */
67
+ export declare function saveScenario(opts: SaveScenarioOptions): Promise<string>;
68
+ /**
69
+ * Load a saved scenario from disk by name.
70
+ *
71
+ * Accepts the original name (will be sanitized) or the sanitized form.
72
+ */
73
+ export declare function loadScenario(name: string): Promise<ScenarioData>;
74
+ /**
75
+ * List all saved scenarios, returning lightweight summary objects.
76
+ *
77
+ * Scenarios with unreadable or malformed files are silently skipped.
78
+ */
79
+ export declare function listScenarios(): Promise<ScenarioSummary[]>;
80
+ /**
81
+ * Compare two or more scenarios side by side.
82
+ *
83
+ * For each program that appears in any of the loaded scenarios, the result
84
+ * includes the actual unit count and the projected units from each scenario.
85
+ * Programs missing from a given scenario will have projectedUnits of 0.
86
+ */
87
+ export declare function compareScenarios(names: string[]): Promise<ComparisonResult>;
88
+ /**
89
+ * Delete a scenario file from disk.
90
+ *
91
+ * Throws if the scenario does not exist.
92
+ */
93
+ export declare function deleteScenario(name: string): Promise<void>;
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Budget scenario manager — save, load, list, compare, and delete named scenarios.
3
+ *
4
+ * Scenarios are stored as JSON files on disk at:
5
+ * /home/optimal/optimal-cli/data/scenarios/{name}.json
6
+ *
7
+ * Each scenario captures a full snapshot of projected units after applying
8
+ * a uniform adjustment to the live fpa_wes_imports data.
9
+ */
10
+ import 'dotenv/config';
11
+ import { mkdirSync, writeFileSync, readFileSync, readdirSync, unlinkSync } from 'node:fs';
12
+ import { join, dirname } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { fetchWesImports, initializeProjections, applyUniformAdjustment, calculateTotals, } from './projections.js';
15
+ // --- Directory resolution ---
16
+ const __filename = fileURLToPath(import.meta.url);
17
+ const __dirname = dirname(__filename);
18
+ // Resolve relative to the repo root (lib/budget/ -> ../../data/scenarios/)
19
+ const SCENARIOS_DIR = join(__dirname, '..', '..', 'data', 'scenarios');
20
+ function ensureScenariosDir() {
21
+ mkdirSync(SCENARIOS_DIR, { recursive: true });
22
+ }
23
+ // --- Helpers ---
24
+ /**
25
+ * Sanitize a scenario name into a safe filename segment.
26
+ * Lowercases, replaces spaces and disallowed chars with hyphens,
27
+ * collapses repeated hyphens, and strips leading/trailing hyphens.
28
+ */
29
+ function sanitizeName(name) {
30
+ return name
31
+ .toLowerCase()
32
+ .replace(/[^a-z0-9-_]+/g, '-')
33
+ .replace(/-{2,}/g, '-')
34
+ .replace(/^-|-$/g, '');
35
+ }
36
+ function scenarioPath(sanitized) {
37
+ return join(SCENARIOS_DIR, `${sanitized}.json`);
38
+ }
39
+ // --- Public API ---
40
+ /**
41
+ * Save current projections as a named scenario to disk.
42
+ *
43
+ * Fetches live data via fetchWesImports, applies the given adjustment,
44
+ * calculates totals, and writes the result as JSON.
45
+ *
46
+ * @returns The absolute path to the saved scenario file.
47
+ */
48
+ export async function saveScenario(opts) {
49
+ ensureScenariosDir();
50
+ const sanitized = sanitizeName(opts.name);
51
+ if (!sanitized) {
52
+ throw new Error(`Invalid scenario name: "${opts.name}"`);
53
+ }
54
+ // Fetch and process data
55
+ const summary = await fetchWesImports({
56
+ fiscalYear: opts.fiscalYear,
57
+ userId: opts.userId,
58
+ });
59
+ const initialized = initializeProjections(summary);
60
+ const adjusted = applyUniformAdjustment(initialized, opts.adjustmentType, opts.adjustmentValue);
61
+ const totals = calculateTotals(adjusted);
62
+ const scenarioData = {
63
+ name: opts.name,
64
+ createdAt: new Date().toISOString(),
65
+ adjustmentType: opts.adjustmentType,
66
+ adjustmentValue: opts.adjustmentValue,
67
+ ...(opts.description !== undefined ? { description: opts.description } : {}),
68
+ projections: adjusted.map((p) => ({
69
+ programCode: p.programCode,
70
+ masterProgram: p.masterProgram,
71
+ actualUnits: p.actualUnits,
72
+ projectedUnits: p.projectedUnits,
73
+ })),
74
+ totals: {
75
+ totalActual: totals.totalActual,
76
+ totalProjected: totals.totalProjected,
77
+ percentageChange: totals.percentageChange,
78
+ },
79
+ };
80
+ const filePath = scenarioPath(sanitized);
81
+ writeFileSync(filePath, JSON.stringify(scenarioData, null, 2), 'utf-8');
82
+ return filePath;
83
+ }
84
+ /**
85
+ * Load a saved scenario from disk by name.
86
+ *
87
+ * Accepts the original name (will be sanitized) or the sanitized form.
88
+ */
89
+ export async function loadScenario(name) {
90
+ const sanitized = sanitizeName(name);
91
+ const filePath = scenarioPath(sanitized);
92
+ let raw;
93
+ try {
94
+ raw = readFileSync(filePath, 'utf-8');
95
+ }
96
+ catch {
97
+ throw new Error(`Scenario not found: "${name}" (looked for ${filePath})`);
98
+ }
99
+ return JSON.parse(raw);
100
+ }
101
+ /**
102
+ * List all saved scenarios, returning lightweight summary objects.
103
+ *
104
+ * Scenarios with unreadable or malformed files are silently skipped.
105
+ */
106
+ export async function listScenarios() {
107
+ ensureScenariosDir();
108
+ let files;
109
+ try {
110
+ files = readdirSync(SCENARIOS_DIR);
111
+ }
112
+ catch {
113
+ return [];
114
+ }
115
+ const jsonFiles = files.filter((f) => f.endsWith('.json'));
116
+ const summaries = [];
117
+ for (const file of jsonFiles) {
118
+ const filePath = join(SCENARIOS_DIR, file);
119
+ try {
120
+ const raw = readFileSync(filePath, 'utf-8');
121
+ const data = JSON.parse(raw);
122
+ summaries.push({
123
+ name: data.name,
124
+ createdAt: data.createdAt,
125
+ adjustmentType: data.adjustmentType,
126
+ adjustmentValue: data.adjustmentValue,
127
+ ...(data.description !== undefined ? { description: data.description } : {}),
128
+ totalProjected: data.totals.totalProjected,
129
+ percentageChange: data.totals.percentageChange,
130
+ });
131
+ }
132
+ catch {
133
+ // Skip unreadable/malformed scenario files
134
+ }
135
+ }
136
+ // Sort newest first
137
+ summaries.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
138
+ return summaries;
139
+ }
140
+ /**
141
+ * Compare two or more scenarios side by side.
142
+ *
143
+ * For each program that appears in any of the loaded scenarios, the result
144
+ * includes the actual unit count and the projected units from each scenario.
145
+ * Programs missing from a given scenario will have projectedUnits of 0.
146
+ */
147
+ export async function compareScenarios(names) {
148
+ if (names.length < 2) {
149
+ throw new Error('compareScenarios requires at least 2 scenario names');
150
+ }
151
+ // Load all scenarios in parallel
152
+ const loaded = await Promise.all(names.map((n) => loadScenario(n)));
153
+ // Build a unified map of programCode -> { masterProgram, actual, projectedByScenario }
154
+ const programMap = new Map();
155
+ for (const scenario of loaded) {
156
+ for (const p of scenario.projections) {
157
+ const existing = programMap.get(p.programCode);
158
+ if (existing) {
159
+ existing.projectedByScenario[scenario.name] = p.projectedUnits;
160
+ // Keep the actual from whichever scenario we see first
161
+ }
162
+ else {
163
+ programMap.set(p.programCode, {
164
+ masterProgram: p.masterProgram,
165
+ actual: p.actualUnits,
166
+ projectedByScenario: { [scenario.name]: p.projectedUnits },
167
+ });
168
+ }
169
+ }
170
+ }
171
+ // Fill in zeros for scenarios that don't have a given program
172
+ for (const entry of programMap.values()) {
173
+ for (const scenario of loaded) {
174
+ if (!(scenario.name in entry.projectedByScenario)) {
175
+ entry.projectedByScenario[scenario.name] = 0;
176
+ }
177
+ }
178
+ }
179
+ const programs = Array.from(programMap.entries())
180
+ .map(([programCode, entry]) => ({
181
+ programCode,
182
+ masterProgram: entry.masterProgram,
183
+ actual: entry.actual,
184
+ projectedByScenario: entry.projectedByScenario,
185
+ }))
186
+ .sort((a, b) => a.programCode.localeCompare(b.programCode));
187
+ const totalsByScenario = {};
188
+ for (const scenario of loaded) {
189
+ totalsByScenario[scenario.name] = {
190
+ totalProjected: scenario.totals.totalProjected,
191
+ percentageChange: scenario.totals.percentageChange,
192
+ };
193
+ }
194
+ return {
195
+ scenarioNames: loaded.map((s) => s.name),
196
+ programs,
197
+ totalsByScenario,
198
+ };
199
+ }
200
+ /**
201
+ * Delete a scenario file from disk.
202
+ *
203
+ * Throws if the scenario does not exist.
204
+ */
205
+ export async function deleteScenario(name) {
206
+ const sanitized = sanitizeName(name);
207
+ const filePath = scenarioPath(sanitized);
208
+ try {
209
+ unlinkSync(filePath);
210
+ }
211
+ catch {
212
+ throw new Error(`Scenario not found: "${name}" (looked for ${filePath})`);
213
+ }
214
+ }