optimal-cli 1.0.1 → 1.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 (185) hide show
  1. package/.claude-plugin/marketplace.json +18 -0
  2. package/.claude-plugin/plugin.json +10 -0
  3. package/.env.example +17 -0
  4. package/CLAUDE.md +67 -0
  5. package/COMMANDS.md +264 -0
  6. package/PUBLISH.md +70 -0
  7. package/agents/content-ops.md +2 -2
  8. package/agents/financial-ops.md +2 -2
  9. package/agents/infra-ops.md +2 -2
  10. package/apps/.gitkeep +0 -0
  11. package/bin/optimal.ts +1418 -0
  12. package/docs/MIGRATION_NEEDED.md +37 -0
  13. package/docs/plans/.gitkeep +0 -0
  14. package/docs/plans/optimal-cli-config-registry-v1.md +71 -0
  15. package/hooks/.gitkeep +0 -0
  16. package/lib/budget/projections.ts +561 -0
  17. package/lib/budget/scenarios.ts +312 -0
  18. package/lib/cms/publish-blog.ts +129 -0
  19. package/lib/cms/strapi-client.ts +302 -0
  20. package/lib/config/registry.ts +229 -0
  21. package/lib/config/schema.ts +58 -0
  22. package/lib/config.ts +247 -0
  23. package/lib/infra/.gitkeep +0 -0
  24. package/lib/infra/deploy.ts +70 -0
  25. package/lib/infra/migrate.ts +141 -0
  26. package/lib/kanban-obsidian.ts +232 -0
  27. package/lib/kanban-sync.ts +258 -0
  28. package/lib/kanban.ts +239 -0
  29. package/lib/newsletter/.gitkeep +0 -0
  30. package/lib/newsletter/distribute.ts +256 -0
  31. package/{dist/lib/newsletter/generate-insurance.d.ts → lib/newsletter/generate-insurance.ts} +24 -7
  32. package/lib/newsletter/generate.ts +735 -0
  33. package/lib/obsidian-tasks.ts +231 -0
  34. package/lib/returnpro/.gitkeep +0 -0
  35. package/lib/returnpro/anomalies.ts +258 -0
  36. package/lib/returnpro/audit.ts +194 -0
  37. package/lib/returnpro/diagnose.ts +400 -0
  38. package/lib/returnpro/kpis.ts +255 -0
  39. package/lib/returnpro/templates.ts +323 -0
  40. package/lib/returnpro/upload-income.ts +311 -0
  41. package/lib/returnpro/upload-netsuite.ts +696 -0
  42. package/lib/returnpro/upload-r1.ts +563 -0
  43. package/lib/social/post-generator.ts +468 -0
  44. package/lib/social/publish.ts +301 -0
  45. package/lib/social/scraper.ts +503 -0
  46. package/lib/supabase.ts +25 -0
  47. package/lib/transactions/delete-batch.ts +258 -0
  48. package/lib/transactions/ingest.ts +659 -0
  49. package/lib/transactions/stamp.ts +654 -0
  50. package/package.json +5 -18
  51. package/pnpm-workspace.yaml +3 -0
  52. package/scripts/check-table.ts +24 -0
  53. package/scripts/create-tables.ts +94 -0
  54. package/scripts/migrate-kanban.sh +28 -0
  55. package/scripts/migrate-v2.ts +78 -0
  56. package/scripts/migrate.ts +79 -0
  57. package/scripts/run-migration.ts +59 -0
  58. package/scripts/seed-board.ts +203 -0
  59. package/scripts/test-kanban.ts +21 -0
  60. package/skills/audit-financials/SKILL.md +33 -0
  61. package/skills/board-create/SKILL.md +28 -0
  62. package/skills/board-update/SKILL.md +27 -0
  63. package/skills/board-view/SKILL.md +27 -0
  64. package/skills/delete-batch/SKILL.md +77 -0
  65. package/skills/deploy/SKILL.md +40 -0
  66. package/skills/diagnose-months/SKILL.md +68 -0
  67. package/skills/distribute-newsletter/SKILL.md +58 -0
  68. package/skills/export-budget/SKILL.md +44 -0
  69. package/skills/export-kpis/SKILL.md +52 -0
  70. package/skills/generate-netsuite-template/SKILL.md +51 -0
  71. package/skills/generate-newsletter/SKILL.md +53 -0
  72. package/skills/generate-newsletter-insurance/SKILL.md +59 -0
  73. package/skills/generate-social-posts/SKILL.md +67 -0
  74. package/skills/health-check/SKILL.md +42 -0
  75. package/skills/ingest-transactions/SKILL.md +51 -0
  76. package/skills/manage-cms/SKILL.md +50 -0
  77. package/skills/manage-scenarios/SKILL.md +83 -0
  78. package/skills/migrate-db/SKILL.md +79 -0
  79. package/skills/preview-newsletter/SKILL.md +50 -0
  80. package/skills/project-budget/SKILL.md +60 -0
  81. package/skills/publish-blog/SKILL.md +70 -0
  82. package/skills/publish-social-posts/SKILL.md +70 -0
  83. package/skills/rate-anomalies/SKILL.md +62 -0
  84. package/skills/scrape-ads/SKILL.md +49 -0
  85. package/skills/stamp-transactions/SKILL.md +62 -0
  86. package/skills/upload-income-statements/SKILL.md +54 -0
  87. package/skills/upload-netsuite/SKILL.md +56 -0
  88. package/skills/upload-r1/SKILL.md +45 -0
  89. package/supabase/.temp/cli-latest +1 -0
  90. package/supabase/migrations/.gitkeep +0 -0
  91. package/supabase/migrations/20250305000001_create_agent_configs.sql +36 -0
  92. package/supabase/migrations/20260305111300_create_cli_config_registry.sql +22 -0
  93. package/supabase/migrations/20260306195000_create_kanban_tables.sql +97 -0
  94. package/tests/config-command-smoke.test.ts +395 -0
  95. package/tests/config-registry.test.ts +173 -0
  96. package/tsconfig.json +19 -0
  97. package/agents/profiles.json +0 -5
  98. package/dist/bin/optimal.d.ts +0 -2
  99. package/dist/bin/optimal.js +0 -1590
  100. package/dist/lib/assets/index.d.ts +0 -79
  101. package/dist/lib/assets/index.js +0 -153
  102. package/dist/lib/assets.d.ts +0 -20
  103. package/dist/lib/assets.js +0 -112
  104. package/dist/lib/auth/index.d.ts +0 -83
  105. package/dist/lib/auth/index.js +0 -146
  106. package/dist/lib/board/index.d.ts +0 -39
  107. package/dist/lib/board/index.js +0 -285
  108. package/dist/lib/board/types.d.ts +0 -111
  109. package/dist/lib/board/types.js +0 -1
  110. package/dist/lib/bot/claim.d.ts +0 -3
  111. package/dist/lib/bot/claim.js +0 -20
  112. package/dist/lib/bot/coordinator.d.ts +0 -27
  113. package/dist/lib/bot/coordinator.js +0 -178
  114. package/dist/lib/bot/heartbeat.d.ts +0 -6
  115. package/dist/lib/bot/heartbeat.js +0 -30
  116. package/dist/lib/bot/index.d.ts +0 -9
  117. package/dist/lib/bot/index.js +0 -6
  118. package/dist/lib/bot/protocol.d.ts +0 -12
  119. package/dist/lib/bot/protocol.js +0 -74
  120. package/dist/lib/bot/reporter.d.ts +0 -3
  121. package/dist/lib/bot/reporter.js +0 -27
  122. package/dist/lib/bot/skills.d.ts +0 -26
  123. package/dist/lib/bot/skills.js +0 -69
  124. package/dist/lib/budget/projections.d.ts +0 -115
  125. package/dist/lib/budget/projections.js +0 -384
  126. package/dist/lib/budget/scenarios.d.ts +0 -93
  127. package/dist/lib/budget/scenarios.js +0 -214
  128. package/dist/lib/cms/publish-blog.d.ts +0 -62
  129. package/dist/lib/cms/publish-blog.js +0 -74
  130. package/dist/lib/cms/strapi-client.d.ts +0 -123
  131. package/dist/lib/cms/strapi-client.js +0 -213
  132. package/dist/lib/config/registry.d.ts +0 -17
  133. package/dist/lib/config/registry.js +0 -182
  134. package/dist/lib/config/schema.d.ts +0 -31
  135. package/dist/lib/config/schema.js +0 -25
  136. package/dist/lib/config.d.ts +0 -55
  137. package/dist/lib/config.js +0 -206
  138. package/dist/lib/errors.d.ts +0 -25
  139. package/dist/lib/errors.js +0 -91
  140. package/dist/lib/format.d.ts +0 -28
  141. package/dist/lib/format.js +0 -98
  142. package/dist/lib/infra/deploy.d.ts +0 -29
  143. package/dist/lib/infra/deploy.js +0 -58
  144. package/dist/lib/infra/migrate.d.ts +0 -34
  145. package/dist/lib/infra/migrate.js +0 -103
  146. package/dist/lib/newsletter/distribute.d.ts +0 -52
  147. package/dist/lib/newsletter/distribute.js +0 -193
  148. package/dist/lib/newsletter/generate-insurance.js +0 -36
  149. package/dist/lib/newsletter/generate.d.ts +0 -104
  150. package/dist/lib/newsletter/generate.js +0 -571
  151. package/dist/lib/returnpro/anomalies.d.ts +0 -64
  152. package/dist/lib/returnpro/anomalies.js +0 -166
  153. package/dist/lib/returnpro/audit.d.ts +0 -32
  154. package/dist/lib/returnpro/audit.js +0 -147
  155. package/dist/lib/returnpro/diagnose.d.ts +0 -52
  156. package/dist/lib/returnpro/diagnose.js +0 -281
  157. package/dist/lib/returnpro/kpis.d.ts +0 -32
  158. package/dist/lib/returnpro/kpis.js +0 -192
  159. package/dist/lib/returnpro/templates.d.ts +0 -48
  160. package/dist/lib/returnpro/templates.js +0 -229
  161. package/dist/lib/returnpro/upload-income.d.ts +0 -25
  162. package/dist/lib/returnpro/upload-income.js +0 -235
  163. package/dist/lib/returnpro/upload-netsuite.d.ts +0 -37
  164. package/dist/lib/returnpro/upload-netsuite.js +0 -566
  165. package/dist/lib/returnpro/upload-r1.d.ts +0 -48
  166. package/dist/lib/returnpro/upload-r1.js +0 -398
  167. package/dist/lib/returnpro/validate.d.ts +0 -37
  168. package/dist/lib/returnpro/validate.js +0 -124
  169. package/dist/lib/social/meta.d.ts +0 -90
  170. package/dist/lib/social/meta.js +0 -160
  171. package/dist/lib/social/post-generator.d.ts +0 -83
  172. package/dist/lib/social/post-generator.js +0 -333
  173. package/dist/lib/social/publish.d.ts +0 -66
  174. package/dist/lib/social/publish.js +0 -226
  175. package/dist/lib/social/scraper.d.ts +0 -67
  176. package/dist/lib/social/scraper.js +0 -361
  177. package/dist/lib/supabase.d.ts +0 -4
  178. package/dist/lib/supabase.js +0 -20
  179. package/dist/lib/transactions/delete-batch.d.ts +0 -60
  180. package/dist/lib/transactions/delete-batch.js +0 -203
  181. package/dist/lib/transactions/ingest.d.ts +0 -43
  182. package/dist/lib/transactions/ingest.js +0 -555
  183. package/dist/lib/transactions/stamp.d.ts +0 -51
  184. package/dist/lib/transactions/stamp.js +0 -524
  185. package/docs/CLI-REFERENCE.md +0 -361
@@ -0,0 +1,37 @@
1
+ # Supabase Migration Required
2
+
3
+ The `cli_config_registry` table needs to be created in your Supabase database for the config sync feature to work.
4
+
5
+ ## Manual Steps
6
+
7
+ 1. Go to your Supabase project: https://app.supabase.com/project/_/sql
8
+ 2. Run this SQL:
9
+
10
+ ```sql
11
+ create table if not exists public.cli_config_registry (
12
+ id uuid primary key default gen_random_uuid(),
13
+ owner text not null,
14
+ profile text not null default 'default',
15
+ config_version text not null,
16
+ payload jsonb not null,
17
+ payload_hash text not null,
18
+ source text not null default 'optimal-cli',
19
+ updated_by text,
20
+ updated_at timestamptz not null default now(),
21
+ created_at timestamptz not null default now(),
22
+ unique (owner, profile)
23
+ );
24
+
25
+ create index if not exists idx_cli_config_registry_owner_profile
26
+ on public.cli_config_registry (owner, profile);
27
+
28
+ create index if not exists idx_cli_config_registry_updated_at
29
+ on public.cli_config_registry (updated_at desc);
30
+ ```
31
+
32
+ 3. After running the migration, the `optimal config sync push/pull` commands will work.
33
+
34
+ ## File Location
35
+
36
+ The migration file is also available at:
37
+ `optimal-cli/supabase/migrations/20260305111300_create_cli_config_registry.sql`
File without changes
@@ -0,0 +1,71 @@
1
+ # optimal-cli shared config registry v1 (draft)
2
+
3
+ ## objective
4
+ define a repeatable config-sharing model for `optimal-cli` with versioned schema, supabase-backed sync, and clear command surface.
5
+
6
+ ## v1 scope
7
+ - single-user + team-ready config profile storage
8
+ - deterministic import/export format
9
+ - pull/push sync with conflict visibility
10
+ - schema migration path (`version` field)
11
+
12
+ ## config schema (v1)
13
+ ```json
14
+ {
15
+ "version": "1.0.0",
16
+ "profile": {
17
+ "name": "default",
18
+ "owner": "clenisa",
19
+ "updated_at": "2026-03-05T04:40:00-05:00"
20
+ },
21
+ "providers": {
22
+ "supabase": {
23
+ "project_ref": "<ref>",
24
+ "url": "<url>",
25
+ "anon_key_present": true
26
+ },
27
+ "strapi": {
28
+ "base_url": "https://strapi.op-hub.com",
29
+ "token_present": true
30
+ }
31
+ },
32
+ "defaults": {
33
+ "brand": "CRE-11TRUST",
34
+ "timezone": "America/New_York"
35
+ },
36
+ "features": {
37
+ "cms": true,
38
+ "tasks": true,
39
+ "deploy": false
40
+ }
41
+ }
42
+ ```
43
+
44
+ ## supabase model (proposed)
45
+ `cli_config_registry`
46
+ - `id uuid pk`
47
+ - `owner text not null`
48
+ - `profile_name text not null`
49
+ - `schema_version text not null`
50
+ - `config_json jsonb not null`
51
+ - `config_hash text not null`
52
+ - `updated_at timestamptz not null default now()`
53
+ - unique `(owner, profile_name)`
54
+
55
+ ## command surface (v1)
56
+ - `optimal config init [--profile default]`
57
+ - `optimal config export --out ./optimal.config.json`
58
+ - `optimal config import --file ./optimal.config.json [--merge|--replace]`
59
+ - `optimal config sync pull [--profile default]`
60
+ - `optimal config sync push [--profile default] [--force]`
61
+ - `optimal config doctor`
62
+
63
+ ## conflict model
64
+ - compare local `config_hash` vs remote `config_hash`
65
+ - if diverged and no `--force`, abort with resolution hints
66
+ - write pull/merge decisions to local audit log (`~/.optimal/config-history.log`)
67
+
68
+ ## next implementation step
69
+ 1. add `lib/config/schema.ts` + zod validator
70
+ 2. add `bin/optimal.ts` `config` command group with `doctor` + `export`
71
+ 3. scaffold supabase read/write adapter in `lib/config/registry.ts`
package/hooks/.gitkeep ADDED
File without changes
@@ -0,0 +1,561 @@
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
+
18
+ import { getSupabase } from '../supabase.js'
19
+
20
+ // --- Types ---
21
+
22
+ export interface CheckedInUnitsSummary {
23
+ programCode: string
24
+ masterProgram: string
25
+ masterProgramId: number | null
26
+ clientId: number | null
27
+ clientName: string
28
+ unitCount: number
29
+ countMethod: 'Unit' | 'Pallet'
30
+ avgRetail?: number
31
+ monthLabel?: string
32
+ }
33
+
34
+ export interface ProjectionEntry {
35
+ programCode: string
36
+ masterProgram: string
37
+ masterProgramId: number | null
38
+ clientId: number | null
39
+ clientName: string
40
+ // Unit projections
41
+ actualUnits: number
42
+ adjustmentType: 'percentage' | 'flat'
43
+ adjustmentValue: number
44
+ projectedUnits: number
45
+ // Average retail projections
46
+ avgRetail?: number
47
+ avgRetailAdjustmentType: 'percentage' | 'flat'
48
+ avgRetailAdjustmentValue: number
49
+ projectedAvgRetail?: number
50
+ }
51
+
52
+ export interface ProjectionInput {
53
+ actualUnits: number
54
+ adjustmentType: 'percentage' | 'flat'
55
+ adjustmentValue: number
56
+ }
57
+
58
+ export interface ProjectionTotals {
59
+ totalActual: number
60
+ totalProjected: number
61
+ percentageChange: number
62
+ absoluteChange: number
63
+ actualRevenue: number
64
+ projectedRevenue: number
65
+ revenueChange: number
66
+ revenuePercentChange: number
67
+ }
68
+
69
+ // --- Core calculation functions ---
70
+
71
+ /**
72
+ * Calculate projected units based on adjustment type and value.
73
+ */
74
+ export function calculateProjection(input: ProjectionInput): number {
75
+ const { actualUnits, adjustmentType, adjustmentValue } = input
76
+
77
+ if (adjustmentType === 'percentage') {
78
+ return Math.round(actualUnits * (1 + adjustmentValue / 100))
79
+ } else {
80
+ return Math.max(0, actualUnits + adjustmentValue)
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Calculate projected average retail price.
86
+ */
87
+ export function calculateAvgRetailProjection(
88
+ actualAvgRetail: number | undefined,
89
+ adjustmentType: 'percentage' | 'flat',
90
+ adjustmentValue: number,
91
+ ): number | undefined {
92
+ if (actualAvgRetail == null) return undefined
93
+
94
+ if (adjustmentType === 'percentage') {
95
+ return actualAvgRetail * (1 + adjustmentValue / 100)
96
+ } else {
97
+ return Math.max(0, actualAvgRetail + adjustmentValue)
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Convert checked-in units summary to projection entries with default values (0% change).
103
+ */
104
+ export function initializeProjections(
105
+ summary: CheckedInUnitsSummary[],
106
+ ): ProjectionEntry[] {
107
+ return (summary ?? []).map((item) => {
108
+ const units = typeof item.unitCount === 'number' ? item.unitCount : 0
109
+ const retail = typeof item.avgRetail === 'number' ? item.avgRetail : undefined
110
+ return {
111
+ programCode: item.programCode ?? '',
112
+ masterProgram: item.masterProgram ?? '',
113
+ masterProgramId: item.masterProgramId ?? null,
114
+ clientId: item.clientId ?? null,
115
+ clientName: item.clientName ?? 'Unknown',
116
+ actualUnits: units,
117
+ adjustmentType: 'percentage' as const,
118
+ adjustmentValue: 0,
119
+ projectedUnits: units,
120
+ avgRetail: retail,
121
+ avgRetailAdjustmentType: 'percentage' as const,
122
+ avgRetailAdjustmentValue: 0,
123
+ projectedAvgRetail: retail,
124
+ }
125
+ })
126
+ }
127
+
128
+ /**
129
+ * Update a single projection entry's units.
130
+ */
131
+ export function updateProjection(
132
+ entry: ProjectionEntry,
133
+ adjustmentType: 'percentage' | 'flat',
134
+ adjustmentValue: number,
135
+ ): ProjectionEntry {
136
+ const projectedUnits = calculateProjection({
137
+ actualUnits: entry.actualUnits,
138
+ adjustmentType,
139
+ adjustmentValue,
140
+ })
141
+ return { ...entry, adjustmentType, adjustmentValue, projectedUnits }
142
+ }
143
+
144
+ /**
145
+ * Update a single projection entry's average retail.
146
+ */
147
+ export function updateAvgRetailProjection(
148
+ entry: ProjectionEntry,
149
+ adjustmentType: 'percentage' | 'flat',
150
+ adjustmentValue: number,
151
+ ): ProjectionEntry {
152
+ const projectedAvgRetail = calculateAvgRetailProjection(
153
+ entry.avgRetail,
154
+ adjustmentType,
155
+ adjustmentValue,
156
+ )
157
+ return {
158
+ ...entry,
159
+ avgRetailAdjustmentType: adjustmentType,
160
+ avgRetailAdjustmentValue: adjustmentValue,
161
+ projectedAvgRetail,
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Apply a uniform unit adjustment to all projections.
167
+ */
168
+ export function applyUniformAdjustment(
169
+ projections: ProjectionEntry[],
170
+ adjustmentType: 'percentage' | 'flat',
171
+ adjustmentValue: number,
172
+ ): ProjectionEntry[] {
173
+ return projections.map((entry) =>
174
+ updateProjection(entry, adjustmentType, adjustmentValue),
175
+ )
176
+ }
177
+
178
+ /**
179
+ * Apply a uniform avg retail adjustment to all projections.
180
+ */
181
+ export function applyUniformAvgRetailAdjustment(
182
+ projections: ProjectionEntry[],
183
+ adjustmentType: 'percentage' | 'flat',
184
+ adjustmentValue: number,
185
+ ): ProjectionEntry[] {
186
+ return projections.map((entry) =>
187
+ updateAvgRetailProjection(entry, adjustmentType, adjustmentValue),
188
+ )
189
+ }
190
+
191
+ /**
192
+ * Calculate totals for projection summary including revenue.
193
+ */
194
+ export function calculateTotals(projections: ProjectionEntry[]): ProjectionTotals {
195
+ const totalActual = projections.reduce((sum, p) => sum + p.actualUnits, 0)
196
+ const totalProjected = projections.reduce((sum, p) => sum + p.projectedUnits, 0)
197
+ const absoluteChange = totalProjected - totalActual
198
+ const percentageChange =
199
+ totalActual > 0
200
+ ? ((totalProjected - totalActual) / totalActual) * 100
201
+ : 0
202
+
203
+ const actualRevenue = projections.reduce((sum, p) => {
204
+ if (p.avgRetail != null) return sum + p.actualUnits * p.avgRetail
205
+ return sum
206
+ }, 0)
207
+
208
+ const projectedRevenue = projections.reduce((sum, p) => {
209
+ if (p.projectedAvgRetail != null)
210
+ return sum + p.projectedUnits * p.projectedAvgRetail
211
+ return sum
212
+ }, 0)
213
+
214
+ const revenueChange = projectedRevenue - actualRevenue
215
+ const revenuePercentChange =
216
+ actualRevenue > 0
217
+ ? ((projectedRevenue - actualRevenue) / actualRevenue) * 100
218
+ : 0
219
+
220
+ return {
221
+ totalActual,
222
+ totalProjected,
223
+ percentageChange,
224
+ absoluteChange,
225
+ actualRevenue,
226
+ projectedRevenue,
227
+ revenueChange,
228
+ revenuePercentChange,
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Group projections by client name.
234
+ */
235
+ export function groupProjectionsByClient(
236
+ projections: ProjectionEntry[],
237
+ ): Map<string, ProjectionEntry[]> {
238
+ const groups = new Map<string, ProjectionEntry[]>()
239
+ for (const p of projections) {
240
+ const list = groups.get(p.clientName) ?? []
241
+ list.push(p)
242
+ groups.set(p.clientName, list)
243
+ }
244
+ return groups
245
+ }
246
+
247
+ /**
248
+ * Export projections to CSV format with unit + avgRetail + inventory value data.
249
+ */
250
+ export function exportToCSV(projections: ProjectionEntry[]): string {
251
+ const headers = [
252
+ 'Program Code',
253
+ 'Master Program',
254
+ 'Client',
255
+ '2025 Actual Units',
256
+ 'Unit Adj Type',
257
+ 'Unit Adj Value',
258
+ '2026 Projected Units',
259
+ 'Unit Change',
260
+ 'Unit Change %',
261
+ '2025 Avg Retail',
262
+ 'Retail Adj Type',
263
+ 'Retail Adj Value',
264
+ '2026 Projected Retail',
265
+ 'Retail Change',
266
+ 'Retail Change %',
267
+ '2025 Inventory Value',
268
+ '2026 Projected Inv. Value',
269
+ 'Inv. Value Change',
270
+ 'Inv. Value Change %',
271
+ ]
272
+
273
+ const rows = projections.map((p) => {
274
+ const unitChange = p.projectedUnits - p.actualUnits
275
+ const unitChangePct =
276
+ p.actualUnits > 0
277
+ ? ((unitChange / p.actualUnits) * 100).toFixed(1)
278
+ : '0.0'
279
+
280
+ const retailChange = (p.projectedAvgRetail ?? 0) - (p.avgRetail ?? 0)
281
+ const retailChangePct =
282
+ p.avgRetail != null && p.avgRetail > 0
283
+ ? ((retailChange / p.avgRetail) * 100).toFixed(1)
284
+ : '0.0'
285
+
286
+ const actualRev = p.avgRetail != null ? p.actualUnits * p.avgRetail : 0
287
+ const projRev =
288
+ p.projectedAvgRetail != null
289
+ ? p.projectedUnits * p.projectedAvgRetail
290
+ : 0
291
+ const revChange = projRev - actualRev
292
+ const revChangePct =
293
+ actualRev > 0 ? ((revChange / actualRev) * 100).toFixed(1) : '0.0'
294
+
295
+ return [
296
+ csvEscape(p.programCode),
297
+ csvEscape(p.masterProgram),
298
+ csvEscape(p.clientName),
299
+ p.actualUnits,
300
+ p.adjustmentType,
301
+ p.adjustmentType === 'percentage'
302
+ ? `${p.adjustmentValue}%`
303
+ : p.adjustmentValue,
304
+ p.projectedUnits,
305
+ unitChange,
306
+ `${unitChangePct}%`,
307
+ p.avgRetail != null ? `$${p.avgRetail.toFixed(2)}` : '',
308
+ p.avgRetailAdjustmentType,
309
+ p.avgRetailAdjustmentType === 'percentage'
310
+ ? `${p.avgRetailAdjustmentValue}%`
311
+ : `$${p.avgRetailAdjustmentValue}`,
312
+ p.projectedAvgRetail != null
313
+ ? `$${p.projectedAvgRetail.toFixed(2)}`
314
+ : '',
315
+ p.avgRetail != null ? `$${retailChange.toFixed(2)}` : '',
316
+ p.avgRetail != null ? `${retailChangePct}%` : '',
317
+ actualRev > 0 ? `$${actualRev.toFixed(2)}` : '',
318
+ projRev > 0 ? `$${projRev.toFixed(2)}` : '',
319
+ actualRev > 0 ? `$${revChange.toFixed(2)}` : '',
320
+ actualRev > 0 ? `${revChangePct}%` : '',
321
+ ].join(',')
322
+ })
323
+
324
+ return [headers.join(','), ...rows].join('\n')
325
+ }
326
+
327
+ // --- Data fetching ---
328
+
329
+ const PAGE_SIZE = 1000
330
+
331
+ interface WesImportRow {
332
+ program_code: string | null
333
+ master_program_id: number
334
+ actual_units_prior_year: number | null
335
+ projected_units: number | null
336
+ avg_retail_prior_year: number | null
337
+ projected_avg_retail: number | null
338
+ unit_adj_type: string | null
339
+ unit_adj_value: number | null
340
+ retail_adj_type: string | null
341
+ retail_adj_value: number | null
342
+ dim_master_program: {
343
+ master_name: string
344
+ client_id: number | null
345
+ dim_client: { client_name: string } | null
346
+ } | null
347
+ }
348
+
349
+ /**
350
+ * Fetch FY25 actuals from fpa_wes_imports on the ReturnPro Supabase instance.
351
+ * Aggregates across all months for a given fiscal year and user,
352
+ * returning one CheckedInUnitsSummary per master program.
353
+ */
354
+ export async function fetchWesImports(options?: {
355
+ fiscalYear?: number
356
+ userId?: string
357
+ }): Promise<CheckedInUnitsSummary[]> {
358
+ const sb = getSupabase('returnpro')
359
+ const fy = options?.fiscalYear ?? 2025
360
+
361
+ const summaryMap = new Map<
362
+ number,
363
+ {
364
+ programCode: string
365
+ masterProgram: string
366
+ masterProgramId: number
367
+ clientId: number | null
368
+ clientName: string
369
+ totalUnits: number
370
+ retailSum: number
371
+ retailCount: number
372
+ }
373
+ >()
374
+
375
+ let from = 0
376
+ while (true) {
377
+ let query = sb
378
+ .from('fpa_wes_imports')
379
+ .select(
380
+ `
381
+ program_code,
382
+ master_program_id,
383
+ actual_units_prior_year,
384
+ projected_units,
385
+ avg_retail_prior_year,
386
+ projected_avg_retail,
387
+ unit_adj_type,
388
+ unit_adj_value,
389
+ retail_adj_type,
390
+ retail_adj_value,
391
+ dim_master_program(master_name, client_id, dim_client(client_name))
392
+ `,
393
+ )
394
+ .eq('fiscal_year', fy)
395
+ .order('master_program_id')
396
+ .range(from, from + PAGE_SIZE - 1)
397
+
398
+ if (options?.userId) {
399
+ query = query.eq('user_id', options.userId)
400
+ }
401
+
402
+ const { data, error } = await query
403
+
404
+ if (error)
405
+ throw new Error(`Fetch fpa_wes_imports failed: ${error.message}`)
406
+ if (!data || data.length === 0) break
407
+
408
+ for (const row of data as unknown as WesImportRow[]) {
409
+ const mpId = row.master_program_id
410
+ const existing = summaryMap.get(mpId)
411
+ const units = row.actual_units_prior_year ?? row.projected_units ?? 0
412
+ const retail = row.avg_retail_prior_year ?? null
413
+
414
+ if (existing) {
415
+ existing.totalUnits += units
416
+ if (retail != null) {
417
+ existing.retailSum += retail
418
+ existing.retailCount += 1
419
+ }
420
+ } else {
421
+ const dim = row.dim_master_program
422
+ summaryMap.set(mpId, {
423
+ programCode: row.program_code ?? '',
424
+ masterProgram: dim?.master_name ?? '',
425
+ masterProgramId: mpId,
426
+ clientId: dim?.client_id ?? null,
427
+ clientName: dim?.dim_client?.client_name ?? 'Unknown',
428
+ totalUnits: units,
429
+ retailSum: retail ?? 0,
430
+ retailCount: retail != null ? 1 : 0,
431
+ })
432
+ }
433
+ }
434
+
435
+ if (data.length < PAGE_SIZE) break
436
+ from += PAGE_SIZE
437
+ }
438
+
439
+ const results: CheckedInUnitsSummary[] = []
440
+ for (const entry of summaryMap.values()) {
441
+ results.push({
442
+ programCode: entry.programCode,
443
+ masterProgram: entry.masterProgram,
444
+ masterProgramId: entry.masterProgramId,
445
+ clientId: entry.clientId,
446
+ clientName: entry.clientName,
447
+ unitCount: entry.totalUnits,
448
+ countMethod: 'Unit',
449
+ avgRetail:
450
+ entry.retailCount > 0
451
+ ? entry.retailSum / entry.retailCount
452
+ : undefined,
453
+ })
454
+ }
455
+
456
+ results.sort((a, b) => a.clientName.localeCompare(b.clientName) || a.masterProgram.localeCompare(b.masterProgram))
457
+ return results
458
+ }
459
+
460
+ /**
461
+ * Parse a JSON file (array of CheckedInUnitsSummary) as an alternative data source.
462
+ * Accepts raw JSON string (e.g., from stdin or file read).
463
+ */
464
+ export function parseSummaryFromJson(json: string): CheckedInUnitsSummary[] {
465
+ const data = JSON.parse(json) as CheckedInUnitsSummary[]
466
+ if (!Array.isArray(data)) {
467
+ throw new Error('Expected a JSON array of CheckedInUnitsSummary objects')
468
+ }
469
+ return data
470
+ }
471
+
472
+ // --- Formatting helpers ---
473
+
474
+ function csvEscape(s: string): string {
475
+ if (s.includes(',') || s.includes('"') || s.includes('\n')) {
476
+ return `"${s.replace(/"/g, '""')}"`
477
+ }
478
+ return s
479
+ }
480
+
481
+ function fmtCompact(n: number): string {
482
+ const abs = Math.abs(n)
483
+ const sign = n < 0 ? '-' : ''
484
+ if (abs >= 1_000_000) return `${sign}$${(abs / 1_000_000).toFixed(1)}M`
485
+ if (abs >= 1_000) return `${sign}$${(abs / 1_000).toFixed(1)}K`
486
+ return `${sign}$${abs.toFixed(0)}`
487
+ }
488
+
489
+ function fmtUnits(n: number): string {
490
+ if (Math.abs(n) >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`
491
+ if (Math.abs(n) >= 1_000) return `${(n / 1_000).toFixed(1)}K`
492
+ return String(n)
493
+ }
494
+
495
+ function fmtDelta(pct: number): string {
496
+ const arrow = pct >= 0 ? '\u2191' : '\u2193'
497
+ return `${arrow}${pct >= 0 ? '+' : ''}${pct.toFixed(1)}%`
498
+ }
499
+
500
+ /**
501
+ * Format projections as a Bloomberg-dense markdown table.
502
+ */
503
+ export function formatProjectionTable(projections: ProjectionEntry[]): string {
504
+ if (projections.length === 0) return 'No projection data.'
505
+
506
+ const totals = calculateTotals(projections)
507
+ const lines: string[] = []
508
+
509
+ // Summary header
510
+ lines.push(
511
+ `FY25 Actual: ${fmtUnits(totals.totalActual)} units | FY26 Projected: ${fmtUnits(totals.totalProjected)} units | ${fmtDelta(totals.percentageChange)}`,
512
+ )
513
+ if (totals.actualRevenue > 0) {
514
+ lines.push(
515
+ `Revenue: ${fmtCompact(totals.actualRevenue)} -> ${fmtCompact(totals.projectedRevenue)} | ${fmtDelta(totals.revenuePercentChange)}`,
516
+ )
517
+ }
518
+ lines.push('')
519
+
520
+ // Table
521
+ lines.push(
522
+ '| Client | Program | FY25 Units | FY26 Units | Delta | Avg Retail | Proj Retail | Rev Delta |',
523
+ )
524
+ lines.push(
525
+ '|--------|---------|------------|------------|-------|------------|-------------|-----------|',
526
+ )
527
+
528
+ for (const p of projections) {
529
+ const unitDelta = p.projectedUnits - p.actualUnits
530
+ const unitPct =
531
+ p.actualUnits > 0
532
+ ? ((unitDelta / p.actualUnits) * 100).toFixed(1)
533
+ : '0.0'
534
+ const deltaStr = `${unitDelta >= 0 ? '+' : ''}${fmtUnits(unitDelta)} (${unitPct}%)`
535
+
536
+ const retailStr =
537
+ p.avgRetail != null ? `$${p.avgRetail.toFixed(2)}` : '-'
538
+ const projRetailStr =
539
+ p.projectedAvgRetail != null
540
+ ? `$${p.projectedAvgRetail.toFixed(2)}`
541
+ : '-'
542
+
543
+ const actualRev = p.avgRetail != null ? p.actualUnits * p.avgRetail : 0
544
+ const projRev =
545
+ p.projectedAvgRetail != null
546
+ ? p.projectedUnits * p.projectedAvgRetail
547
+ : 0
548
+ const revDelta = projRev - actualRev
549
+ const revDeltaStr =
550
+ actualRev > 0
551
+ ? `${revDelta >= 0 ? '+' : ''}${fmtCompact(revDelta)}`
552
+ : '-'
553
+
554
+ lines.push(
555
+ `| ${p.clientName} | ${p.programCode} | ${fmtUnits(p.actualUnits)} | ${fmtUnits(p.projectedUnits)} | ${deltaStr} | ${retailStr} | ${projRetailStr} | ${revDeltaStr} |`,
556
+ )
557
+ }
558
+
559
+ lines.push(`\n${projections.length} programs`)
560
+ return lines.join('\n')
561
+ }