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
@@ -1,400 +0,0 @@
1
- import { getSupabase } from '../supabase.js'
2
-
3
- // --- Types ---
4
-
5
- export type DiagnosticIssueKind =
6
- | 'unresolved_account_code'
7
- | 'unresolved_program_code'
8
- | 'unresolved_master_program'
9
- | 'unresolved_client'
10
- | 'low_row_count'
11
- | 'missing_month'
12
- | 'null_date_rows'
13
- | 'null_account_code_rows'
14
-
15
- export interface DiagnosticIssue {
16
- /** Category of the problem. */
17
- kind: DiagnosticIssueKind
18
- /** YYYY-MM if the issue is month-scoped, null if global. */
19
- month: string | null
20
- /** Short human-readable summary. */
21
- message: string
22
- /** Optional payload with supporting data. */
23
- detail?: Record<string, unknown>
24
- }
25
-
26
- export interface DiagnosisResult {
27
- /** YYYY-MM months that were analysed. */
28
- monthsAnalysed: string[]
29
- /** Total rows in stg_financials_raw across the analysed months. */
30
- totalRows: number
31
- /** Per-month row counts. */
32
- rowsPerMonth: Record<string, number>
33
- /** Median row count across months — used to flag anomalously low months. */
34
- medianRowCount: number
35
- /** All issues found. */
36
- issues: DiagnosticIssue[]
37
- /** Convenience summary counts. */
38
- summary: {
39
- unresolvedAccountCodes: number
40
- unresolvedProgramCodes: number
41
- unresolvedMasterPrograms: number
42
- unresolvedClients: number
43
- lowRowCountMonths: number
44
- missingMonths: number
45
- totalIssues: number
46
- }
47
- }
48
-
49
- // --- Internal row shapes ---
50
-
51
- interface StagingRow {
52
- raw_id: number
53
- date: string | null
54
- account_code: string | null
55
- account_id: number | null
56
- program_code: string | null
57
- program_id_key: number | null
58
- master_program: string | null
59
- master_program_id: number | null
60
- client_id: number | null
61
- }
62
-
63
- interface DimAccount {
64
- account_code: string
65
- }
66
-
67
- interface DimProgramId {
68
- program_code: string
69
- master_program_id: number | null
70
- }
71
-
72
- interface DimMasterProgram {
73
- master_program_id: number
74
- master_name: string
75
- client_id: number | null
76
- }
77
-
78
- interface DimClient {
79
- client_id: number
80
- client_name: string
81
- }
82
-
83
- // --- Helpers ---
84
-
85
- const PAGE_SIZE = 1000
86
-
87
- /** Fetch all rows from a table with pagination, bypassing the 1000-row cap. */
88
- async function paginateAll<T>(
89
- table: string,
90
- select: string,
91
- orderCol: string,
92
- ): Promise<T[]> {
93
- const sb = getSupabase('returnpro')
94
- const all: T[] = []
95
- let from = 0
96
-
97
- while (true) {
98
- const { data, error } = await sb
99
- .from(table)
100
- .select(select)
101
- .order(orderCol)
102
- .range(from, from + PAGE_SIZE - 1)
103
-
104
- if (error) throw new Error(`Fetch ${table} failed: ${error.message}`)
105
- if (!data || data.length === 0) break
106
-
107
- all.push(...(data as T[]))
108
- if (data.length < PAGE_SIZE) break
109
- from += PAGE_SIZE
110
- }
111
-
112
- return all
113
- }
114
-
115
- /** Compute the median of a numeric array. Returns 0 for empty arrays. */
116
- function median(values: number[]): number {
117
- if (values.length === 0) return 0
118
- const sorted = [...values].sort((a, b) => a - b)
119
- const mid = Math.floor(sorted.length / 2)
120
- return sorted.length % 2 === 0
121
- ? (sorted[mid - 1] + sorted[mid]) / 2
122
- : sorted[mid]
123
- }
124
-
125
- /**
126
- * Extract YYYY-MM from a date string. Returns null if unparseable.
127
- */
128
- function toYearMonth(date: string): string | null {
129
- if (!date) return null
130
- const d = new Date(date)
131
- if (isNaN(d.getTime())) return null
132
- return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
133
- }
134
-
135
- /**
136
- * Build the list of expected YYYY-MM months between the earliest and latest
137
- * months seen in staging data. Used to detect completely missing months.
138
- */
139
- function buildExpectedMonths(present: string[]): string[] {
140
- if (present.length === 0) return []
141
- const sorted = [...present].sort()
142
- const first = sorted[0]
143
- const last = sorted[sorted.length - 1]
144
-
145
- const [fy, fm] = first.split('-').map(Number)
146
- const [ly, lm] = last.split('-').map(Number)
147
-
148
- const expected: string[] = []
149
- let y = fy
150
- let m = fm
151
- while (y < ly || (y === ly && m <= lm)) {
152
- expected.push(`${y}-${String(m).padStart(2, '0')}`)
153
- m++
154
- if (m > 12) { m = 1; y++ }
155
- }
156
- return expected
157
- }
158
-
159
- // --- Core ---
160
-
161
- /**
162
- * Diagnose FK resolution failures and data gaps in stg_financials_raw.
163
- *
164
- * Checks performed:
165
- * 1. Rows with null date or null account_code (data quality)
166
- * 2. account_codes not present in dim_account
167
- * 3. program_codes not present in dim_program_id
168
- * 4. program_codes whose dim_program_id row has a null master_program_id
169
- * 5. master_program_ids not present in dim_master_program
170
- * 6. master_programs whose dim_master_program row has a null client_id
171
- * 7. Months with row counts < 50% of the median (anomalously low)
172
- * 8. Calendar months completely absent between the first and last month seen
173
- *
174
- * @param options.months - If provided, only analyse these YYYY-MM months.
175
- * If omitted, all months present in staging are analysed.
176
- */
177
- export async function diagnoseMonths(
178
- options?: { months?: string[] },
179
- ): Promise<DiagnosisResult> {
180
- const issues: DiagnosticIssue[] = []
181
-
182
- // --- 1. Load all staging rows (paginated) ---
183
- const stagingRows = await paginateAll<StagingRow>(
184
- 'stg_financials_raw',
185
- 'raw_id,date,account_code,account_id,program_code,program_id_key,master_program,master_program_id,client_id',
186
- 'raw_id',
187
- )
188
-
189
- // --- 2. Load all dimension tables in parallel ---
190
- const [dimAccounts, dimProgramIds, dimMasterPrograms, dimClients] = await Promise.all([
191
- paginateAll<DimAccount>('dim_account', 'account_code', 'account_code'),
192
- paginateAll<DimProgramId>('dim_program_id', 'program_code,master_program_id', 'program_code'),
193
- paginateAll<DimMasterProgram>('dim_master_program', 'master_program_id,master_name,client_id', 'master_program_id'),
194
- paginateAll<DimClient>('dim_client', 'client_id,client_name', 'client_id'),
195
- ])
196
-
197
- // Build lookup sets
198
- const knownAccountCodes = new Set(dimAccounts.map(r => r.account_code))
199
- const knownProgramCodes = new Set(dimProgramIds.map(r => r.program_code))
200
- // dim_program_id entries that have a null master_program_id (orphaned program codes)
201
- const orphanedProgramCodes = new Set(
202
- dimProgramIds.filter(r => r.master_program_id === null).map(r => r.program_code),
203
- )
204
- const knownMasterProgramIds = new Set(dimMasterPrograms.map(r => r.master_program_id))
205
- // master programs without a client
206
- const masterProgramsWithoutClient = new Set(
207
- dimMasterPrograms.filter(r => r.client_id === null).map(r => r.master_program_id),
208
- )
209
- const knownClientIds = new Set(dimClients.map(r => r.client_id))
210
-
211
- // --- 3. Assign staging rows to months ---
212
- const rowsByMonth = new Map<string, StagingRow[]>()
213
- let nullDateCount = 0
214
- let nullAccountCodeCount = 0
215
-
216
- for (const row of stagingRows) {
217
- if (!row.date) {
218
- nullDateCount++
219
- continue
220
- }
221
- const ym = toYearMonth(row.date)
222
- if (!ym) {
223
- nullDateCount++
224
- continue
225
- }
226
- const existing = rowsByMonth.get(ym) ?? []
227
- existing.push(row)
228
- rowsByMonth.set(ym, existing)
229
-
230
- if (!row.account_code) nullAccountCodeCount++
231
- }
232
-
233
- // --- 4. Apply month filter ---
234
- let targetMonths: string[]
235
- if (options?.months && options.months.length > 0) {
236
- targetMonths = options.months.filter(m => rowsByMonth.has(m)).sort()
237
- } else {
238
- targetMonths = [...rowsByMonth.keys()].sort()
239
- }
240
-
241
- // --- 5. Global data quality issues ---
242
- if (nullDateCount > 0) {
243
- issues.push({
244
- kind: 'null_date_rows',
245
- month: null,
246
- message: `${nullDateCount} row(s) in stg_financials_raw have a null or unparseable date`,
247
- detail: { count: nullDateCount },
248
- })
249
- }
250
- if (nullAccountCodeCount > 0) {
251
- issues.push({
252
- kind: 'null_account_code_rows',
253
- month: null,
254
- message: `${nullAccountCodeCount} row(s) in stg_financials_raw have a null account_code`,
255
- detail: { count: nullAccountCodeCount },
256
- })
257
- }
258
-
259
- // --- 6. Per-month analysis ---
260
- const rowsPerMonth: Record<string, number> = {}
261
-
262
- // Aggregate FK failure sets per dimension (global, deduplicated)
263
- const unresolvedAccountCodes = new Set<string>()
264
- const unresolvedProgramCodes = new Set<string>()
265
- const unresolvedMasterProgramIds = new Set<number>()
266
- const unresolvedClientIds = new Set<number>()
267
-
268
- for (const month of targetMonths) {
269
- const rows = rowsByMonth.get(month) ?? []
270
- rowsPerMonth[month] = rows.length
271
-
272
- for (const row of rows) {
273
- // account_code → dim_account
274
- if (row.account_code && !knownAccountCodes.has(row.account_code)) {
275
- unresolvedAccountCodes.add(row.account_code)
276
- }
277
-
278
- // program_code → dim_program_id
279
- if (row.program_code) {
280
- if (!knownProgramCodes.has(row.program_code)) {
281
- unresolvedProgramCodes.add(row.program_code)
282
- } else if (orphanedProgramCodes.has(row.program_code)) {
283
- // The program_code exists in dim_program_id but its master_program_id is null
284
- unresolvedProgramCodes.add(row.program_code)
285
- }
286
- }
287
-
288
- // master_program_id → dim_master_program
289
- if (row.master_program_id !== null && row.master_program_id !== undefined) {
290
- if (!knownMasterProgramIds.has(row.master_program_id)) {
291
- unresolvedMasterProgramIds.add(row.master_program_id)
292
- } else if (masterProgramsWithoutClient.has(row.master_program_id)) {
293
- // master_program exists but has no client_id
294
- unresolvedClientIds.add(row.master_program_id)
295
- }
296
- }
297
-
298
- // client_id → dim_client (direct FK on staging row)
299
- if (row.client_id !== null && row.client_id !== undefined) {
300
- if (!knownClientIds.has(row.client_id)) {
301
- unresolvedClientIds.add(row.client_id)
302
- }
303
- }
304
- }
305
- }
306
-
307
- // Emit per-dimension issues (global, not per-month — less noise)
308
- if (unresolvedAccountCodes.size > 0) {
309
- const codes = [...unresolvedAccountCodes].sort()
310
- issues.push({
311
- kind: 'unresolved_account_code',
312
- month: null,
313
- message: `${codes.length} account_code(s) in staging do not resolve to dim_account`,
314
- detail: { codes },
315
- })
316
- }
317
-
318
- if (unresolvedProgramCodes.size > 0) {
319
- const codes = [...unresolvedProgramCodes].sort()
320
- issues.push({
321
- kind: 'unresolved_program_code',
322
- month: null,
323
- message: `${codes.length} program_code(s) in staging do not resolve (missing from dim_program_id or dim_program_id.master_program_id is null)`,
324
- detail: { codes },
325
- })
326
- }
327
-
328
- if (unresolvedMasterProgramIds.size > 0) {
329
- const ids = [...unresolvedMasterProgramIds].sort((a, b) => a - b)
330
- issues.push({
331
- kind: 'unresolved_master_program',
332
- month: null,
333
- message: `${ids.length} master_program_id(s) in staging do not resolve to dim_master_program`,
334
- detail: { ids },
335
- })
336
- }
337
-
338
- if (unresolvedClientIds.size > 0) {
339
- const ids = [...unresolvedClientIds].sort((a, b) => a - b)
340
- issues.push({
341
- kind: 'unresolved_client',
342
- month: null,
343
- message: `${ids.length} client_id(s) in staging do not resolve to dim_client (or master_program has no client)`,
344
- detail: { ids },
345
- })
346
- }
347
-
348
- // --- 7. Row count anomalies (< 50% of median) ---
349
- const counts = targetMonths.map(m => rowsPerMonth[m] ?? 0)
350
- const med = median(counts)
351
- const lowThreshold = med * 0.5
352
-
353
- for (const month of targetMonths) {
354
- const count = rowsPerMonth[month] ?? 0
355
- if (med > 0 && count < lowThreshold) {
356
- issues.push({
357
- kind: 'low_row_count',
358
- month,
359
- message: `${month} has only ${count} rows — below 50% of median (${med})`,
360
- detail: { rowCount: count, median: med, threshold: lowThreshold },
361
- })
362
- }
363
- }
364
-
365
- // --- 8. Missing months (gaps in the calendar range) ---
366
- const allPresentMonths = [...rowsByMonth.keys()].sort()
367
- const expectedMonths = buildExpectedMonths(allPresentMonths)
368
- const presentSet = new Set(allPresentMonths)
369
-
370
- for (const expected of expectedMonths) {
371
- // Only flag months within the analysed range that are entirely absent
372
- if (!presentSet.has(expected)) {
373
- issues.push({
374
- kind: 'missing_month',
375
- month: expected,
376
- message: `${expected} has no rows in stg_financials_raw — month is missing`,
377
- })
378
- }
379
- }
380
-
381
- // --- 9. Compute summary ---
382
- const summary = {
383
- unresolvedAccountCodes: unresolvedAccountCodes.size,
384
- unresolvedProgramCodes: unresolvedProgramCodes.size,
385
- unresolvedMasterPrograms: unresolvedMasterProgramIds.size,
386
- unresolvedClients: unresolvedClientIds.size,
387
- lowRowCountMonths: issues.filter(i => i.kind === 'low_row_count').length,
388
- missingMonths: issues.filter(i => i.kind === 'missing_month').length,
389
- totalIssues: issues.length,
390
- }
391
-
392
- return {
393
- monthsAnalysed: targetMonths,
394
- totalRows: counts.reduce((a, b) => a + b, 0),
395
- rowsPerMonth,
396
- medianRowCount: med,
397
- issues,
398
- summary,
399
- }
400
- }
@@ -1,255 +0,0 @@
1
- import { getSupabase } from '../supabase.js'
2
-
3
- // --- Types ---
4
-
5
- export interface KpiRow {
6
- month: string
7
- kpiName: string
8
- kpiBucket: string
9
- programName: string
10
- clientName: string
11
- totalAmount: number
12
- }
13
-
14
- interface RpcResult {
15
- kpi_id: number
16
- kpi_name: string
17
- kpi_bucket: string
18
- master_program_id: number
19
- master_name: string
20
- client_id: number
21
- client_name: string
22
- month: string
23
- total_amount: string | number
24
- }
25
-
26
- // --- Helpers ---
27
-
28
- const PAGE_SIZE = 1000
29
-
30
- /**
31
- * Fetch distinct months available in stg_financials_raw.
32
- * Returns sorted YYYY-MM strings.
33
- */
34
- async function fetchAvailableMonths(): Promise<string[]> {
35
- const sb = getSupabase('returnpro')
36
- const months = new Set<string>()
37
- let from = 0
38
-
39
- while (true) {
40
- const { data, error } = await sb
41
- .from('stg_financials_raw')
42
- .select('date')
43
- .order('date')
44
- .range(from, from + PAGE_SIZE - 1)
45
-
46
- if (error) throw new Error(`Fetch months failed: ${error.message}`)
47
- if (!data || data.length === 0) break
48
-
49
- for (const row of data as Array<{ date: string }>) {
50
- if (row.date) months.add(row.date.substring(0, 7))
51
- }
52
-
53
- if (data.length < PAGE_SIZE) break
54
- from += PAGE_SIZE
55
- }
56
-
57
- return [...months].sort()
58
- }
59
-
60
- /**
61
- * Call the get_kpi_totals_by_program_client RPC function for a single month.
62
- * Optionally filter by master_program_id.
63
- */
64
- async function fetchKpisByMonth(
65
- month: string,
66
- masterProgramId?: number,
67
- ): Promise<RpcResult[]> {
68
- const sb = getSupabase('returnpro')
69
- const params: Record<string, unknown> = { p_month: month }
70
- if (masterProgramId !== undefined) {
71
- params.p_master_program_id = masterProgramId
72
- }
73
-
74
- const { data, error } = await sb.rpc('get_kpi_totals_by_program_client', params)
75
-
76
- if (error) throw new Error(`RPC get_kpi_totals_by_program_client failed for ${month}: ${error.message}`)
77
- return (data ?? []) as RpcResult[]
78
- }
79
-
80
- /**
81
- * Resolve program name filter to master_program_id(s).
82
- * Searches both dim_master_program.master_name (full names like "Bass Pro Shops Liquidation (Finished)")
83
- * and dim_program_id.program_name (codes like "BRTON-WM-LIQ") for case-insensitive partial match.
84
- */
85
- async function resolveProgramIds(names: string[]): Promise<number[]> {
86
- const sb = getSupabase('returnpro')
87
-
88
- // Fetch both tables in parallel
89
- const [masterRes, programRes] = await Promise.all([
90
- sb.from('dim_master_program').select('master_program_id,master_name').order('master_name'),
91
- sb.from('dim_program_id').select('program_id_key,program_code,master_program_id').order('program_code'),
92
- ])
93
-
94
- if (masterRes.error) throw new Error(`Fetch dim_master_program failed: ${masterRes.error.message}`)
95
- if (programRes.error) throw new Error(`Fetch dim_program_id failed: ${programRes.error.message}`)
96
-
97
- const lowerNames = names.map(n => n.toLowerCase())
98
- const ids = new Set<number>()
99
-
100
- // Match against master program names
101
- for (const row of (masterRes.data ?? []) as Array<{ master_program_id: number; master_name: string }>) {
102
- if (lowerNames.some(n => row.master_name.toLowerCase().includes(n))) {
103
- ids.add(row.master_program_id)
104
- }
105
- }
106
-
107
- // Match against program codes and map back to master_program_id
108
- for (const row of (programRes.data ?? []) as Array<{ program_id_key: number; program_code: string; master_program_id: number | null }>) {
109
- if (row.master_program_id && lowerNames.some(n => row.program_code.toLowerCase().includes(n))) {
110
- ids.add(row.master_program_id)
111
- }
112
- }
113
-
114
- return [...ids]
115
- }
116
-
117
- // --- Core ---
118
-
119
- export interface ExportKpiOptions {
120
- /** YYYY-MM months to export. If omitted, uses the 3 most recent months. */
121
- months?: string[]
122
- /** Program name substrings to filter by. Case-insensitive partial match. */
123
- programs?: string[]
124
- }
125
-
126
- /**
127
- * Export KPI data from ReturnPro, aggregated by program/client/month.
128
- *
129
- * Calls the `get_kpi_totals_by_program_client` Supabase RPC function
130
- * (same as dashboard-returnpro's /api/kpis/by-program-client route).
131
- *
132
- * @returns Flat array of KpiRow sorted by month, kpiName, clientName, programName.
133
- */
134
- export async function exportKpis(options?: ExportKpiOptions): Promise<KpiRow[]> {
135
- // Resolve months
136
- let targetMonths: string[]
137
- if (options?.months && options.months.length > 0) {
138
- targetMonths = options.months.sort()
139
- } else {
140
- const allMonths = await fetchAvailableMonths()
141
- // Default to 3 most recent months
142
- targetMonths = allMonths.slice(-3)
143
- }
144
-
145
- if (targetMonths.length === 0) {
146
- return []
147
- }
148
-
149
- // Resolve program filter
150
- let programIds: number[] | undefined
151
- if (options?.programs && options.programs.length > 0) {
152
- programIds = await resolveProgramIds(options.programs)
153
- if (programIds.length === 0) {
154
- console.error(`No programs matched: ${options.programs.join(', ')}`)
155
- return []
156
- }
157
- }
158
-
159
- // Fetch KPI data for each month
160
- // If we have program filter, call once per programId x month
161
- // If no program filter, call once per month (no filter)
162
- const allRows: KpiRow[] = []
163
-
164
- for (const month of targetMonths) {
165
- if (programIds && programIds.length > 0) {
166
- // Call per program to use the RPC filter
167
- for (const pid of programIds) {
168
- const results = await fetchKpisByMonth(month, pid)
169
- for (const row of results) {
170
- allRows.push(mapRow(row))
171
- }
172
- }
173
- } else {
174
- const results = await fetchKpisByMonth(month)
175
- for (const row of results) {
176
- allRows.push(mapRow(row))
177
- }
178
- }
179
- }
180
-
181
- // Sort: month, kpiName, clientName, programName
182
- allRows.sort((a, b) =>
183
- a.month.localeCompare(b.month)
184
- || a.kpiName.localeCompare(b.kpiName)
185
- || a.clientName.localeCompare(b.clientName)
186
- || a.programName.localeCompare(b.programName)
187
- )
188
-
189
- return allRows
190
- }
191
-
192
- function mapRow(row: RpcResult): KpiRow {
193
- return {
194
- month: row.month,
195
- kpiName: row.kpi_name,
196
- kpiBucket: row.kpi_bucket,
197
- programName: row.master_name ?? '- None -',
198
- clientName: row.client_name ?? 'Unknown',
199
- totalAmount: typeof row.total_amount === 'string'
200
- ? parseFloat(row.total_amount) || 0
201
- : Number(row.total_amount) || 0,
202
- }
203
- }
204
-
205
- // --- Formatting ---
206
-
207
- /**
208
- * Format KPI rows as a compact markdown table.
209
- * Amounts shown in compact notation ($1.2M, $890K).
210
- */
211
- export function formatKpiTable(rows: KpiRow[]): string {
212
- if (rows.length === 0) return 'No KPI data found.'
213
-
214
- const lines: string[] = []
215
- lines.push('| Month | KPI | Bucket | Client | Program | Amount |')
216
- lines.push('|---------|-----|--------|--------|---------|--------|')
217
-
218
- for (const r of rows) {
219
- lines.push(
220
- `| ${r.month} | ${r.kpiName} | ${r.kpiBucket} | ${r.clientName} | ${r.programName} | ${fmtAmount(r.totalAmount)} |`
221
- )
222
- }
223
-
224
- lines.push(`\n${rows.length} rows`)
225
- return lines.join('\n')
226
- }
227
-
228
- /**
229
- * Format KPI rows as CSV.
230
- */
231
- export function formatKpiCsv(rows: KpiRow[]): string {
232
- const lines: string[] = []
233
- lines.push('month,kpi_name,kpi_bucket,client_name,program_name,total_amount')
234
- for (const r of rows) {
235
- lines.push(
236
- `${r.month},${csvEscape(r.kpiName)},${csvEscape(r.kpiBucket)},${csvEscape(r.clientName)},${csvEscape(r.programName)},${r.totalAmount.toFixed(2)}`
237
- )
238
- }
239
- return lines.join('\n')
240
- }
241
-
242
- function csvEscape(s: string): string {
243
- if (s.includes(',') || s.includes('"') || s.includes('\n')) {
244
- return `"${s.replace(/"/g, '""')}"`
245
- }
246
- return s
247
- }
248
-
249
- function fmtAmount(n: number): string {
250
- const abs = Math.abs(n)
251
- const sign = n < 0 ? '-' : ''
252
- if (abs >= 1_000_000) return `${sign}$${(abs / 1_000_000).toFixed(1)}M`
253
- if (abs >= 1_000) return `${sign}$${(abs / 1_000).toFixed(1)}K`
254
- return `${sign}$${abs.toFixed(0)}`
255
- }