optimal-cli 0.1.0 → 1.0.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 (107) hide show
  1. package/agents/.gitkeep +0 -0
  2. package/agents/content-ops.md +227 -0
  3. package/agents/financial-ops.md +184 -0
  4. package/agents/infra-ops.md +206 -0
  5. package/agents/profiles.json +5 -0
  6. package/bin/optimal.ts +1731 -0
  7. package/docs/CLI-REFERENCE.md +361 -0
  8. package/lib/assets/index.ts +225 -0
  9. package/lib/assets.ts +124 -0
  10. package/lib/auth/index.ts +189 -0
  11. package/lib/board/index.ts +309 -0
  12. package/lib/board/types.ts +124 -0
  13. package/lib/bot/claim.ts +43 -0
  14. package/lib/bot/coordinator.ts +254 -0
  15. package/lib/bot/heartbeat.ts +37 -0
  16. package/lib/bot/index.ts +9 -0
  17. package/lib/bot/protocol.ts +99 -0
  18. package/lib/bot/reporter.ts +42 -0
  19. package/lib/bot/skills.ts +81 -0
  20. package/lib/budget/projections.ts +561 -0
  21. package/lib/budget/scenarios.ts +312 -0
  22. package/lib/cms/publish-blog.ts +129 -0
  23. package/lib/cms/strapi-client.ts +302 -0
  24. package/lib/config/registry.ts +228 -0
  25. package/lib/config/schema.ts +58 -0
  26. package/lib/config.ts +247 -0
  27. package/lib/errors.ts +129 -0
  28. package/lib/format.ts +120 -0
  29. package/lib/infra/.gitkeep +0 -0
  30. package/lib/infra/deploy.ts +70 -0
  31. package/lib/infra/migrate.ts +141 -0
  32. package/lib/newsletter/.gitkeep +0 -0
  33. package/lib/newsletter/distribute.ts +256 -0
  34. package/{dist/lib/newsletter/generate-insurance.d.ts → lib/newsletter/generate-insurance.ts} +24 -7
  35. package/lib/newsletter/generate.ts +735 -0
  36. package/lib/returnpro/.gitkeep +0 -0
  37. package/lib/returnpro/anomalies.ts +258 -0
  38. package/lib/returnpro/audit.ts +194 -0
  39. package/lib/returnpro/diagnose.ts +400 -0
  40. package/lib/returnpro/kpis.ts +255 -0
  41. package/lib/returnpro/templates.ts +323 -0
  42. package/lib/returnpro/upload-income.ts +311 -0
  43. package/lib/returnpro/upload-netsuite.ts +696 -0
  44. package/lib/returnpro/upload-r1.ts +563 -0
  45. package/lib/returnpro/validate.ts +154 -0
  46. package/lib/social/meta.ts +228 -0
  47. package/lib/social/post-generator.ts +468 -0
  48. package/lib/social/publish.ts +301 -0
  49. package/lib/social/scraper.ts +503 -0
  50. package/lib/supabase.ts +25 -0
  51. package/lib/transactions/delete-batch.ts +258 -0
  52. package/lib/transactions/ingest.ts +659 -0
  53. package/lib/transactions/stamp.ts +654 -0
  54. package/package.json +15 -25
  55. package/dist/bin/optimal.d.ts +0 -2
  56. package/dist/bin/optimal.js +0 -995
  57. package/dist/lib/budget/projections.d.ts +0 -115
  58. package/dist/lib/budget/projections.js +0 -384
  59. package/dist/lib/budget/scenarios.d.ts +0 -93
  60. package/dist/lib/budget/scenarios.js +0 -214
  61. package/dist/lib/cms/publish-blog.d.ts +0 -62
  62. package/dist/lib/cms/publish-blog.js +0 -74
  63. package/dist/lib/cms/strapi-client.d.ts +0 -123
  64. package/dist/lib/cms/strapi-client.js +0 -213
  65. package/dist/lib/config.d.ts +0 -55
  66. package/dist/lib/config.js +0 -206
  67. package/dist/lib/infra/deploy.d.ts +0 -29
  68. package/dist/lib/infra/deploy.js +0 -58
  69. package/dist/lib/infra/migrate.d.ts +0 -34
  70. package/dist/lib/infra/migrate.js +0 -103
  71. package/dist/lib/kanban.d.ts +0 -46
  72. package/dist/lib/kanban.js +0 -118
  73. package/dist/lib/newsletter/distribute.d.ts +0 -52
  74. package/dist/lib/newsletter/distribute.js +0 -193
  75. package/dist/lib/newsletter/generate-insurance.js +0 -36
  76. package/dist/lib/newsletter/generate.d.ts +0 -104
  77. package/dist/lib/newsletter/generate.js +0 -571
  78. package/dist/lib/returnpro/anomalies.d.ts +0 -64
  79. package/dist/lib/returnpro/anomalies.js +0 -166
  80. package/dist/lib/returnpro/audit.d.ts +0 -32
  81. package/dist/lib/returnpro/audit.js +0 -147
  82. package/dist/lib/returnpro/diagnose.d.ts +0 -52
  83. package/dist/lib/returnpro/diagnose.js +0 -281
  84. package/dist/lib/returnpro/kpis.d.ts +0 -32
  85. package/dist/lib/returnpro/kpis.js +0 -192
  86. package/dist/lib/returnpro/templates.d.ts +0 -48
  87. package/dist/lib/returnpro/templates.js +0 -229
  88. package/dist/lib/returnpro/upload-income.d.ts +0 -25
  89. package/dist/lib/returnpro/upload-income.js +0 -235
  90. package/dist/lib/returnpro/upload-netsuite.d.ts +0 -37
  91. package/dist/lib/returnpro/upload-netsuite.js +0 -566
  92. package/dist/lib/returnpro/upload-r1.d.ts +0 -48
  93. package/dist/lib/returnpro/upload-r1.js +0 -398
  94. package/dist/lib/social/post-generator.d.ts +0 -83
  95. package/dist/lib/social/post-generator.js +0 -333
  96. package/dist/lib/social/publish.d.ts +0 -66
  97. package/dist/lib/social/publish.js +0 -226
  98. package/dist/lib/social/scraper.d.ts +0 -67
  99. package/dist/lib/social/scraper.js +0 -361
  100. package/dist/lib/supabase.d.ts +0 -4
  101. package/dist/lib/supabase.js +0 -20
  102. package/dist/lib/transactions/delete-batch.d.ts +0 -60
  103. package/dist/lib/transactions/delete-batch.js +0 -203
  104. package/dist/lib/transactions/ingest.d.ts +0 -43
  105. package/dist/lib/transactions/ingest.js +0 -555
  106. package/dist/lib/transactions/stamp.d.ts +0 -51
  107. package/dist/lib/transactions/stamp.js +0 -524
@@ -0,0 +1,563 @@
1
+ import * as fs from 'fs'
2
+ import * as path from 'path'
3
+ import ExcelJS from 'exceljs'
4
+ import { getSupabase } from '../supabase.js'
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Public types
8
+ // ---------------------------------------------------------------------------
9
+
10
+ /**
11
+ * A single row extracted from an R1 XLSX file before aggregation.
12
+ * Columns match the canonical R1 export format documented in dashboard-returnpro.
13
+ */
14
+ export interface R1Row {
15
+ programCode: string
16
+ masterProgram: string
17
+ trgid: string
18
+ locationId: string
19
+ avgRetail: number | null
20
+ }
21
+
22
+ /**
23
+ * Return value of processR1Upload.
24
+ */
25
+ export interface R1UploadResult {
26
+ /** Source file name (basename) */
27
+ sourceFileName: string
28
+ /** YYYY-MM-DD date inserted as the `date` column (always the 1st of the given monthYear) */
29
+ date: string
30
+ /** Total raw rows read from the XLSX (excluding header) */
31
+ totalRowsRead: number
32
+ /** Number of rows skipped due to missing ProgramName, TRGID, or Master Program Name */
33
+ rowsSkipped: number
34
+ /** Number of distinct (masterProgram, programCode, location) groups aggregated */
35
+ programGroupsFound: number
36
+ /** Number of rows actually inserted into stg_financials_raw */
37
+ rowsInserted: number
38
+ /** Any non-fatal warnings encountered during processing */
39
+ warnings: string[]
40
+ }
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Internal types
44
+ // ---------------------------------------------------------------------------
45
+
46
+ interface AggregateKey {
47
+ masterProgram: string
48
+ masterProgramId: number | null
49
+ programCode: string
50
+ programIdKey: number | null
51
+ clientId: number | null
52
+ location: string
53
+ trgidSet: Set<string>
54
+ locationIdSet: Set<string>
55
+ }
56
+
57
+ interface DimProgramIdRow {
58
+ program_id_key: number
59
+ program_code: string
60
+ }
61
+
62
+ interface DimMasterProgramRow {
63
+ master_program_id: number
64
+ master_name: string
65
+ client_id: number | null
66
+ }
67
+
68
+ interface StgInsertRow {
69
+ source_file_name: string
70
+ loaded_at: string
71
+ user_id: string
72
+ location: string
73
+ master_program: string
74
+ program_code: string
75
+ program_id_key: number | null
76
+ date: string
77
+ account_code: string
78
+ account_id: number
79
+ amount: string
80
+ mode: string
81
+ master_program_id: number | null
82
+ client_id: number | null
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Constants
87
+ // ---------------------------------------------------------------------------
88
+
89
+ /**
90
+ * Account code / ID for Checked-In Qty, which is the primary volume type
91
+ * produced by a standard R1 upload. This matches the dashboard's volume-configs.ts.
92
+ *
93
+ * account_code: "Checked-In Qty"
94
+ * account_id: 130
95
+ */
96
+ const CHECKED_IN_ACCOUNT_CODE = 'Checked-In Qty'
97
+ const CHECKED_IN_ACCOUNT_ID = 130
98
+
99
+ const CHUNK_SIZE = 500
100
+ const PAGE_SIZE = 1000
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Helpers
104
+ // ---------------------------------------------------------------------------
105
+
106
+ /**
107
+ * Extract location from a ProgramName / program code string.
108
+ * - If starts with "DS-", location is "DS"
109
+ * - Otherwise, first 5 characters (uppercased)
110
+ * Mirrors dashboard-returnpro/lib/r1-monthly/processing.ts extractLocation()
111
+ */
112
+ function extractLocation(programName: string): string {
113
+ const trimmed = programName.trim()
114
+ if (!trimmed) return 'UNKNOWN'
115
+ if (trimmed.startsWith('DS-')) return 'DS'
116
+ return trimmed.substring(0, 5).toUpperCase()
117
+ }
118
+
119
+ /**
120
+ * Split an array into fixed-size chunks for batched inserts.
121
+ */
122
+ function chunk<T>(arr: T[], size: number): T[][] {
123
+ const out: T[][] = []
124
+ for (let i = 0; i < arr.length; i += size) {
125
+ out.push(arr.slice(i, i + size))
126
+ }
127
+ return out
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Supabase dim table lookups
132
+ // ---------------------------------------------------------------------------
133
+
134
+ /**
135
+ * Fetch all dim_program_id rows and build a map: program_code -> program_id_key.
136
+ * Fetches ALL rows in pages to avoid the 1000-row Supabase cap.
137
+ * First occurrence wins for any duplicate program_code.
138
+ */
139
+ async function buildProgramIdKeyMap(
140
+ supabaseUrl: string,
141
+ supabaseKey: string,
142
+ ): Promise<Map<string, number>> {
143
+ const map = new Map<string, number>()
144
+ let offset = 0
145
+
146
+ while (true) {
147
+ const url =
148
+ `${supabaseUrl}/rest/v1/dim_program_id` +
149
+ `?select=program_id_key,program_code` +
150
+ `&order=program_id_key` +
151
+ `&offset=${offset}&limit=${PAGE_SIZE}`
152
+
153
+ const res = await fetch(url, {
154
+ headers: { apikey: supabaseKey, Authorization: `Bearer ${supabaseKey}` },
155
+ })
156
+
157
+ if (!res.ok) {
158
+ const text = await res.text()
159
+ throw new Error(`Failed to fetch dim_program_id: ${text}`)
160
+ }
161
+
162
+ const rows = (await res.json()) as DimProgramIdRow[]
163
+ for (const row of rows) {
164
+ if (!map.has(row.program_code)) {
165
+ map.set(row.program_code, row.program_id_key)
166
+ }
167
+ }
168
+
169
+ if (rows.length < PAGE_SIZE) break
170
+ offset += PAGE_SIZE
171
+ }
172
+
173
+ return map
174
+ }
175
+
176
+ /**
177
+ * Fetch all dim_master_program rows and build a map: master_name -> {master_program_id, client_id}.
178
+ * Fetches ALL rows in pages to avoid the 1000-row Supabase cap.
179
+ */
180
+ async function buildMasterProgramMap(
181
+ supabaseUrl: string,
182
+ supabaseKey: string,
183
+ ): Promise<Map<string, DimMasterProgramRow>> {
184
+ const map = new Map<string, DimMasterProgramRow>()
185
+ let offset = 0
186
+
187
+ while (true) {
188
+ const url =
189
+ `${supabaseUrl}/rest/v1/dim_master_program` +
190
+ `?select=master_program_id,master_name,client_id` +
191
+ `&order=master_program_id` +
192
+ `&offset=${offset}&limit=${PAGE_SIZE}`
193
+
194
+ const res = await fetch(url, {
195
+ headers: { apikey: supabaseKey, Authorization: `Bearer ${supabaseKey}` },
196
+ })
197
+
198
+ if (!res.ok) {
199
+ const text = await res.text()
200
+ throw new Error(`Failed to fetch dim_master_program: ${text}`)
201
+ }
202
+
203
+ const rows = (await res.json()) as DimMasterProgramRow[]
204
+ for (const row of rows) {
205
+ if (!map.has(row.master_name)) {
206
+ map.set(row.master_name, row)
207
+ }
208
+ }
209
+
210
+ if (rows.length < PAGE_SIZE) break
211
+ offset += PAGE_SIZE
212
+ }
213
+
214
+ return map
215
+ }
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // XLSX parsing
219
+ // ---------------------------------------------------------------------------
220
+
221
+ /**
222
+ * Parse the first sheet of an R1 XLSX file into R1Row records.
223
+ *
224
+ * Required columns (case-sensitive, matching the Rust WASM parser):
225
+ * ProgramName
226
+ * Master Program Name
227
+ * TRGID
228
+ *
229
+ * Optional columns:
230
+ * LocationID
231
+ * MR_LMR_UPC_AverageCategoryRetail (or "RetailPrice" / "Retail Price")
232
+ *
233
+ * Returns { rows, totalRead, skipped, warnings }
234
+ */
235
+ async function parseR1Xlsx(filePath: string): Promise<{
236
+ rows: R1Row[]
237
+ totalRead: number
238
+ skipped: number
239
+ warnings: string[]
240
+ }> {
241
+ const warnings: string[] = []
242
+ const workbook = new ExcelJS.Workbook()
243
+ await workbook.xlsx.readFile(filePath)
244
+
245
+ const worksheet = workbook.worksheets[0]
246
+ if (!worksheet) {
247
+ throw new Error(`R1 XLSX has no worksheets: ${filePath}`)
248
+ }
249
+
250
+ // Read header row (row 1)
251
+ const headerRow = worksheet.getRow(1)
252
+ const headers: Map<string, number> = new Map()
253
+ headerRow.eachCell({ includeEmpty: false }, (cell, colNum) => {
254
+ const val = String(cell.value ?? '').trim()
255
+ if (val) headers.set(val, colNum)
256
+ })
257
+
258
+ // Resolve required column indices
259
+ const programCol = headers.get('ProgramName')
260
+ const masterCol = headers.get('Master Program Name')
261
+ const trgidCol = headers.get('TRGID')
262
+
263
+ if (programCol === undefined || masterCol === undefined || trgidCol === undefined) {
264
+ throw new Error(
265
+ `R1 XLSX missing required columns. ` +
266
+ `Expected: ProgramName, Master Program Name, TRGID. ` +
267
+ `Found: ${[...headers.keys()].join(', ')}`
268
+ )
269
+ }
270
+
271
+ // Resolve optional column indices
272
+ const locationCol = headers.get('LocationID')
273
+ const retailCol =
274
+ headers.get('MR_LMR_UPC_AverageCategoryRetail') ??
275
+ headers.get('RetailPrice') ??
276
+ headers.get('Retail Price')
277
+
278
+ const rows: R1Row[] = []
279
+ let totalRead = 0
280
+ let skipped = 0
281
+
282
+ worksheet.eachRow({ includeEmpty: false }, (row, rowNum) => {
283
+ if (rowNum === 1) return // skip header
284
+
285
+ totalRead++
286
+
287
+ const programCode = String(row.getCell(programCol).value ?? '').trim()
288
+ const masterProgram = String(row.getCell(masterCol).value ?? '').trim()
289
+ const trgid = String(row.getCell(trgidCol).value ?? '').trim()
290
+
291
+ // Skip rows missing the three required values
292
+ if (!programCode || !masterProgram || !trgid) {
293
+ skipped++
294
+ return
295
+ }
296
+
297
+ const locationId = locationCol
298
+ ? String(row.getCell(locationCol).value ?? '').trim()
299
+ : ''
300
+
301
+ let avgRetail: number | null = null
302
+ if (retailCol !== undefined) {
303
+ const rawRetail = row.getCell(retailCol).value
304
+ if (rawRetail !== null && rawRetail !== undefined && rawRetail !== '') {
305
+ const parsed = typeof rawRetail === 'number' ? rawRetail : parseFloat(String(rawRetail))
306
+ if (!isNaN(parsed) && parsed > 0) avgRetail = parsed
307
+ }
308
+ }
309
+
310
+ rows.push({ programCode, masterProgram, trgid, locationId, avgRetail })
311
+ })
312
+
313
+ if (rows.length === 0 && totalRead > 0) {
314
+ warnings.push(
315
+ `All ${totalRead} rows were skipped (missing ProgramName, Master Program Name, or TRGID). ` +
316
+ `Check that the first sheet contains data with the expected column headers.`
317
+ )
318
+ }
319
+
320
+ return { rows, totalRead, skipped, warnings }
321
+ }
322
+
323
+ // ---------------------------------------------------------------------------
324
+ // Aggregation
325
+ // ---------------------------------------------------------------------------
326
+
327
+ /**
328
+ * Aggregate raw R1 rows into per-(masterProgram, programCode, location) groups.
329
+ * For each group we track distinct TRGIDs and distinct LocationIDs.
330
+ * This mirrors the Rust WASM aggregation in wasm/r1-parser/src/lib.rs.
331
+ *
332
+ * The count stored in `amount` uses distinct TRGID count (Checked-In Qty
333
+ * for most programs). The LocationID set is retained for callers that need it.
334
+ */
335
+ function aggregateRows(rows: R1Row[]): Map<string, AggregateKey> {
336
+ const groups = new Map<string, AggregateKey>()
337
+
338
+ for (const row of rows) {
339
+ const location = extractLocation(row.programCode)
340
+ const key = `${row.masterProgram}|||${row.programCode}|||${location}`
341
+
342
+ let group = groups.get(key)
343
+ if (!group) {
344
+ group = {
345
+ masterProgram: row.masterProgram,
346
+ masterProgramId: null,
347
+ programCode: row.programCode,
348
+ programIdKey: null,
349
+ clientId: null,
350
+ location,
351
+ trgidSet: new Set(),
352
+ locationIdSet: new Set(),
353
+ }
354
+ groups.set(key, group)
355
+ }
356
+
357
+ group.trgidSet.add(row.trgid)
358
+ if (row.locationId) group.locationIdSet.add(row.locationId)
359
+ }
360
+
361
+ return groups
362
+ }
363
+
364
+ // ---------------------------------------------------------------------------
365
+ // Insertion helpers
366
+ // ---------------------------------------------------------------------------
367
+
368
+ /**
369
+ * Insert a batch of rows directly into stg_financials_raw via PostgREST.
370
+ * Returns the number of rows inserted (or throws on failure).
371
+ */
372
+ async function insertBatch(
373
+ supabaseUrl: string,
374
+ supabaseKey: string,
375
+ rows: StgInsertRow[],
376
+ ): Promise<number> {
377
+ const res = await fetch(`${supabaseUrl}/rest/v1/stg_financials_raw`, {
378
+ method: 'POST',
379
+ headers: {
380
+ apikey: supabaseKey,
381
+ Authorization: `Bearer ${supabaseKey}`,
382
+ 'Content-Type': 'application/json',
383
+ Prefer: 'return=minimal',
384
+ },
385
+ body: JSON.stringify(rows),
386
+ })
387
+
388
+ if (!res.ok) {
389
+ const text = await res.text()
390
+ let message = text || res.statusText
391
+ try {
392
+ const payload = JSON.parse(text) as { message?: string; hint?: string; details?: string }
393
+ message = payload.message ?? message
394
+ if (payload.hint) message += ` (Hint: ${payload.hint})`
395
+ if (payload.details) message += ` (Details: ${payload.details})`
396
+ } catch {
397
+ // use raw text
398
+ }
399
+ throw new Error(`Insert batch failed: ${message}`)
400
+ }
401
+
402
+ return rows.length
403
+ }
404
+
405
+ // ---------------------------------------------------------------------------
406
+ // Main export
407
+ // ---------------------------------------------------------------------------
408
+
409
+ /**
410
+ * Parse an R1 XLSX file, aggregate financial data by program, and insert
411
+ * into the ReturnPro `stg_financials_raw` staging table.
412
+ *
413
+ * Flow:
414
+ * 1. Read and parse the XLSX (first sheet, required columns: ProgramName,
415
+ * Master Program Name, TRGID).
416
+ * 2. Aggregate rows into (masterProgram, programCode, location) groups,
417
+ * counting distinct TRGIDs per group.
418
+ * 3. Look up dim_master_program and dim_program_id to resolve FK columns.
419
+ * 4. Insert into stg_financials_raw in batches of 500.
420
+ *
421
+ * @param filePath Absolute path to the R1 XLSX file on disk.
422
+ * @param userId The user_id to stamp on each inserted row.
423
+ * @param monthYear Target month in "YYYY-MM" format (e.g. "2025-10").
424
+ * Stored as the `date` column as "YYYY-MM-01".
425
+ */
426
+ export async function processR1Upload(
427
+ filePath: string,
428
+ userId: string,
429
+ monthYear: string,
430
+ ): Promise<R1UploadResult> {
431
+ if (!fs.existsSync(filePath)) {
432
+ throw new Error(`File not found: ${filePath}`)
433
+ }
434
+
435
+ // Validate monthYear format
436
+ if (!/^\d{4}-\d{2}$/.test(monthYear)) {
437
+ throw new Error(`monthYear must be in YYYY-MM format (e.g. "2025-10"), got: "${monthYear}"`)
438
+ }
439
+
440
+ const sourceFileName = path.basename(filePath)
441
+ const dateStr = `${monthYear}-01`
442
+ const loadedAt = new Date().toISOString()
443
+ const warnings: string[] = []
444
+
445
+ // -------------------------------------------------------------------------
446
+ // 1. Parse XLSX
447
+ // -------------------------------------------------------------------------
448
+ const { rows: rawRows, totalRead, skipped, warnings: parseWarnings } = await parseR1Xlsx(filePath)
449
+ warnings.push(...parseWarnings)
450
+
451
+ if (rawRows.length === 0) {
452
+ return {
453
+ sourceFileName,
454
+ date: dateStr,
455
+ totalRowsRead: totalRead,
456
+ rowsSkipped: skipped,
457
+ programGroupsFound: 0,
458
+ rowsInserted: 0,
459
+ warnings,
460
+ }
461
+ }
462
+
463
+ // -------------------------------------------------------------------------
464
+ // 2. Aggregate rows into groups
465
+ // -------------------------------------------------------------------------
466
+ const groups = aggregateRows(rawRows)
467
+
468
+ // -------------------------------------------------------------------------
469
+ // 3. Fetch dim tables for FK resolution
470
+ // -------------------------------------------------------------------------
471
+ const sb = getSupabase('returnpro')
472
+
473
+ // Pull the connection URL + key from the client's config via env (same env
474
+ // vars that supabase.ts reads from process.env)
475
+ const supabaseUrl = process.env['RETURNPRO_SUPABASE_URL']
476
+ const supabaseKey = process.env['RETURNPRO_SUPABASE_SERVICE_KEY']
477
+
478
+ if (!supabaseUrl || !supabaseKey) {
479
+ throw new Error('Missing env vars: RETURNPRO_SUPABASE_URL, RETURNPRO_SUPABASE_SERVICE_KEY')
480
+ }
481
+
482
+ const [programIdKeyMap, masterProgramMap] = await Promise.all([
483
+ buildProgramIdKeyMap(supabaseUrl, supabaseKey),
484
+ buildMasterProgramMap(supabaseUrl, supabaseKey),
485
+ ])
486
+
487
+ // Track master programs not found in dim_master_program
488
+ const unknownMasterPrograms = new Set<string>()
489
+
490
+ // -------------------------------------------------------------------------
491
+ // 4. Build insert rows
492
+ // -------------------------------------------------------------------------
493
+ const insertRows: StgInsertRow[] = []
494
+
495
+ for (const [, group] of groups) {
496
+ const masterDim = masterProgramMap.get(group.masterProgram)
497
+ if (!masterDim) {
498
+ unknownMasterPrograms.add(group.masterProgram)
499
+ }
500
+
501
+ const trgidCount = group.trgidSet.size
502
+ if (trgidCount === 0) continue
503
+
504
+ insertRows.push({
505
+ source_file_name: sourceFileName,
506
+ loaded_at: loadedAt,
507
+ user_id: userId,
508
+ location: group.location,
509
+ master_program: group.masterProgram,
510
+ program_code: group.programCode,
511
+ program_id_key: programIdKeyMap.get(group.programCode) ?? null,
512
+ date: dateStr,
513
+ account_code: CHECKED_IN_ACCOUNT_CODE,
514
+ account_id: CHECKED_IN_ACCOUNT_ID,
515
+ // amount is TEXT in stg_financials_raw — store as string
516
+ amount: String(trgidCount),
517
+ mode: 'actual',
518
+ master_program_id: masterDim?.master_program_id ?? null,
519
+ client_id: masterDim?.client_id ?? null,
520
+ })
521
+ }
522
+
523
+ if (unknownMasterPrograms.size > 0) {
524
+ warnings.push(
525
+ `${unknownMasterPrograms.size} master program(s) not found in dim_master_program ` +
526
+ `(master_program_id and client_id will be NULL): ` +
527
+ [...unknownMasterPrograms].sort().join(', ')
528
+ )
529
+ }
530
+
531
+ // -------------------------------------------------------------------------
532
+ // 5. Insert in batches of CHUNK_SIZE
533
+ // -------------------------------------------------------------------------
534
+ let totalInserted = 0
535
+ const batches = chunk(insertRows, CHUNK_SIZE)
536
+
537
+ for (const [i, batch] of batches.entries()) {
538
+ try {
539
+ const inserted = await insertBatch(supabaseUrl, supabaseKey, batch)
540
+ totalInserted += inserted
541
+ } catch (err) {
542
+ const message = err instanceof Error ? err.message : String(err)
543
+ throw new Error(
544
+ `Batch ${i + 1}/${batches.length} insert failed after ${totalInserted} rows inserted: ${message}`
545
+ )
546
+ }
547
+ }
548
+
549
+ // Suppress unused variable warning — sb is a valid Supabase client kept
550
+ // as a reference for future use (e.g. RPC calls). The dim lookups above
551
+ // use raw fetch for pagination control (no .range() on PostgREST URL).
552
+ void sb
553
+
554
+ return {
555
+ sourceFileName,
556
+ date: dateStr,
557
+ totalRowsRead: totalRead,
558
+ rowsSkipped: skipped,
559
+ programGroupsFound: groups.size,
560
+ rowsInserted: totalInserted,
561
+ warnings,
562
+ }
563
+ }
@@ -0,0 +1,154 @@
1
+ // ---------------------------------------------------------------------------
2
+ // ReturnPro data validation
3
+ // ---------------------------------------------------------------------------
4
+ //
5
+ // Pre-insert validators for stg_financials_raw and confirmed_income_statements.
6
+ // No external dependencies — pure TypeScript, no Supabase calls.
7
+ // ---------------------------------------------------------------------------
8
+
9
+ // --- Types ---
10
+
11
+ export interface ValidationResult {
12
+ valid: boolean
13
+ errors: string[]
14
+ }
15
+
16
+ export interface BatchValidationResult {
17
+ totalRows: number
18
+ validRows: number
19
+ invalidRows: number
20
+ errors: Array<{ row: number; errors: string[] }>
21
+ }
22
+
23
+ // --- Constants ---
24
+
25
+ /** Required fields for a stg_financials_raw row. */
26
+ const FINANCIAL_REQUIRED_FIELDS = ['client', 'program', 'account_code', 'amount', 'period'] as const
27
+
28
+ /** Required fields for a confirmed_income_statements row. */
29
+ const INCOME_REQUIRED_FIELDS = ['account_id', 'client_id', 'amount', 'period', 'source'] as const
30
+
31
+ /** Matches YYYY-MM format (e.g. "2025-04"). */
32
+ const PERIOD_REGEX = /^\d{4}-(0[1-9]|1[0-2])$/
33
+
34
+ // --- Helpers ---
35
+
36
+ /**
37
+ * Check whether a value is present (not null, undefined, or empty string).
38
+ */
39
+ function isPresent(value: unknown): boolean {
40
+ if (value === null || value === undefined) return false
41
+ if (typeof value === 'string' && value.trim() === '') return false
42
+ return true
43
+ }
44
+
45
+ /**
46
+ * Check whether a value can be parsed as a finite number.
47
+ * Accepts numbers and numeric strings. Rejects NaN, Infinity, empty strings.
48
+ */
49
+ function isNumeric(value: unknown): boolean {
50
+ if (typeof value === 'number') return isFinite(value)
51
+ if (typeof value === 'string') {
52
+ const trimmed = value.trim()
53
+ if (trimmed === '') return false
54
+ const parsed = Number(trimmed)
55
+ return isFinite(parsed)
56
+ }
57
+ return false
58
+ }
59
+
60
+ // --- Core validators ---
61
+
62
+ /**
63
+ * Validate a single row destined for `stg_financials_raw`.
64
+ *
65
+ * Checks:
66
+ * - Required fields: client, program, account_code, amount, period
67
+ * - `amount` is numeric (the DB column is TEXT, so non-numeric strings are a common bug)
68
+ * - `period` matches YYYY-MM format
69
+ */
70
+ export function validateFinancialRow(row: Record<string, unknown>): ValidationResult {
71
+ const errors: string[] = []
72
+
73
+ // Check required fields
74
+ for (const field of FINANCIAL_REQUIRED_FIELDS) {
75
+ if (!isPresent(row[field])) {
76
+ errors.push(`Missing required field: ${field}`)
77
+ }
78
+ }
79
+
80
+ // Validate amount is numeric (only if present, to avoid duplicate error)
81
+ if (isPresent(row.amount) && !isNumeric(row.amount)) {
82
+ errors.push(`amount must be numeric, got: ${JSON.stringify(row.amount)}`)
83
+ }
84
+
85
+ // Validate period format (only if present)
86
+ if (isPresent(row.period)) {
87
+ const period = String(row.period).trim()
88
+ if (!PERIOD_REGEX.test(period)) {
89
+ errors.push(`period must be YYYY-MM format (e.g. "2025-04"), got: "${period}"`)
90
+ }
91
+ }
92
+
93
+ return { valid: errors.length === 0, errors }
94
+ }
95
+
96
+ /**
97
+ * Validate a batch of rows destined for `stg_financials_raw`.
98
+ *
99
+ * Runs `validateFinancialRow` on each row and aggregates results.
100
+ */
101
+ export function validateBatch(rows: Record<string, unknown>[]): BatchValidationResult {
102
+ const batchErrors: Array<{ row: number; errors: string[] }> = []
103
+ let validRows = 0
104
+
105
+ for (let i = 0; i < rows.length; i++) {
106
+ const result = validateFinancialRow(rows[i])
107
+ if (result.valid) {
108
+ validRows++
109
+ } else {
110
+ batchErrors.push({ row: i, errors: result.errors })
111
+ }
112
+ }
113
+
114
+ return {
115
+ totalRows: rows.length,
116
+ validRows,
117
+ invalidRows: rows.length - validRows,
118
+ errors: batchErrors,
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Validate a single row destined for `confirmed_income_statements`.
124
+ *
125
+ * Checks:
126
+ * - Required fields: account_id, client_id, amount, period, source
127
+ * - `amount` is numeric
128
+ * - `period` matches YYYY-MM format
129
+ */
130
+ export function validateIncomeStatementRow(row: Record<string, unknown>): ValidationResult {
131
+ const errors: string[] = []
132
+
133
+ // Check required fields
134
+ for (const field of INCOME_REQUIRED_FIELDS) {
135
+ if (!isPresent(row[field])) {
136
+ errors.push(`Missing required field: ${field}`)
137
+ }
138
+ }
139
+
140
+ // Validate amount is numeric (only if present)
141
+ if (isPresent(row.amount) && !isNumeric(row.amount)) {
142
+ errors.push(`amount must be numeric, got: ${JSON.stringify(row.amount)}`)
143
+ }
144
+
145
+ // Validate period format (only if present)
146
+ if (isPresent(row.period)) {
147
+ const period = String(row.period).trim()
148
+ if (!PERIOD_REGEX.test(period)) {
149
+ errors.push(`period must be YYYY-MM format (e.g. "2025-04"), got: "${period}"`)
150
+ }
151
+ }
152
+
153
+ return { valid: errors.length === 0, errors }
154
+ }