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,696 +0,0 @@
1
- import { readFileSync } from 'fs'
2
- import { basename, extname } from 'path'
3
- import ExcelJS from 'exceljs'
4
- import { getSupabase } from '../supabase.js'
5
-
6
- // ---------------------------------------------------------------------------
7
- // Types
8
- // ---------------------------------------------------------------------------
9
-
10
- export interface NetSuiteUploadResult {
11
- /** Original file name (basename) */
12
- fileName: string
13
- /** ISO timestamp of this upload batch */
14
- loadedAt: string
15
- /** Total rows inserted into stg_financials_raw */
16
- inserted: number
17
- /** Distinct YYYY-MM months present in the inserted rows */
18
- monthsCovered: string[]
19
- /** Non-fatal warnings (e.g. missing FK lookups) */
20
- warnings: string[]
21
- /** Fatal error message if the upload failed entirely */
22
- error?: string
23
- }
24
-
25
- /** Internal row shape after parsing, before FK resolution */
26
- interface ParsedRow {
27
- location: string
28
- master_program: string
29
- program_id: string
30
- date: string // ISO date string YYYY-MM-DD
31
- account_code: string
32
- amount: string // TEXT — stored as string per DB schema
33
- mode: string
34
- }
35
-
36
- /** Row stamped with FK columns, ready for insert */
37
- interface StagingRow {
38
- source_file_name: string
39
- loaded_at: string
40
- location: string
41
- master_program: string
42
- program_code: string
43
- date: string
44
- account_code: string
45
- amount: string
46
- mode: string
47
- account_id: number | null
48
- client_id: number | null
49
- master_program_id: number | null
50
- program_id_key: number | null
51
- }
52
-
53
- // ---------------------------------------------------------------------------
54
- // Constants
55
- // ---------------------------------------------------------------------------
56
-
57
- const CHUNK_SIZE = 500
58
-
59
- /**
60
- * Month names used to detect multi-sheet (monthly tab) workbooks.
61
- * Matches the fiscal calendar used in dashboard-returnpro (Apr=start).
62
- */
63
- const MONTH_NAMES = new Set([
64
- 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
65
- 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
66
- ])
67
-
68
- /**
69
- * NetSuite "Data Entry" sheet meta columns — everything else is an account code.
70
- */
71
- const META_COLUMNS = new Set([
72
- 'Master Program',
73
- 'Program ID',
74
- 'Date (Upload)',
75
- 'Date (Solution7)',
76
- ])
77
-
78
- // ---------------------------------------------------------------------------
79
- // Excel serial date conversion
80
- // ---------------------------------------------------------------------------
81
-
82
- /**
83
- * Convert an Excel serial date number (e.g. 46023) to ISO YYYY-MM-DD.
84
- * Excel epoch is 1899-12-30 (accounting for the Lotus 1-2-3 leap-year bug).
85
- */
86
- function excelSerialToIso(serial: number): string {
87
- const msPerDay = 86400000
88
- const excelEpoch = new Date(Date.UTC(1899, 11, 30))
89
- const date = new Date(excelEpoch.getTime() + serial * msPerDay)
90
- return date.toISOString().slice(0, 10)
91
- }
92
-
93
- /**
94
- * Normalise a date value from an XLSM cell.
95
- * Accepts:
96
- * - JS Date objects (ExcelJS parses dates for typed cells)
97
- * - Excel serial numbers (numeric)
98
- * - ISO string / "Mon YYYY" partial strings
99
- * Returns ISO YYYY-MM-DD, or null if unparseable.
100
- */
101
- function normaliseDate(raw: unknown): string | null {
102
- if (raw == null || raw === '') return null
103
-
104
- // ExcelJS may return a JS Date for date-typed cells
105
- if (raw instanceof Date) {
106
- if (isNaN(raw.getTime())) return null
107
- return raw.toISOString().slice(0, 10)
108
- }
109
-
110
- if (typeof raw === 'number') {
111
- // Excel serial — must be positive and plausible (> 1 = 1900-01-01)
112
- if (raw > 1) return excelSerialToIso(raw)
113
- return null
114
- }
115
-
116
- if (typeof raw === 'string') {
117
- const s = raw.trim()
118
- // Already ISO
119
- if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s
120
- // Try JS Date parse for other formats
121
- const d = new Date(s)
122
- if (!isNaN(d.getTime())) return d.toISOString().slice(0, 10)
123
- }
124
-
125
- return null
126
- }
127
-
128
- // ---------------------------------------------------------------------------
129
- // Sheet detection
130
- // ---------------------------------------------------------------------------
131
-
132
- /**
133
- * Returns true if the workbook has ≥3 sheets whose names match abbreviated
134
- * month names (Jan, Feb, …, Dec). This is the same heuristic used in
135
- * dashboard-returnpro's WesImporter.tsx → hasMonthlySheets().
136
- */
137
- function hasMonthlySheets(sheetNames: string[]): boolean {
138
- const found = sheetNames.filter(name => MONTH_NAMES.has(name.trim()))
139
- return found.length >= 3
140
- }
141
-
142
- // ---------------------------------------------------------------------------
143
- // CSV parsing
144
- // ---------------------------------------------------------------------------
145
-
146
- /**
147
- * Parse a single CSV line, respecting quoted fields.
148
- */
149
- function parseCsvLine(line: string): string[] {
150
- const fields: string[] = []
151
- let current = ''
152
- let inQuotes = false
153
- for (let i = 0; i < line.length; i++) {
154
- const ch = line[i]
155
- if (ch === '"') {
156
- if (!inQuotes) { inQuotes = true; continue }
157
- if (line[i + 1] === '"') { current += '"'; i++; continue }
158
- inQuotes = false
159
- continue
160
- }
161
- if (ch === ',' && !inQuotes) { fields.push(current.trim()); current = ''; continue }
162
- current += ch
163
- }
164
- fields.push(current.trim())
165
- return fields
166
- }
167
-
168
- /**
169
- * Parse a staging CSV file (long format).
170
- * Expected headers: location, master_program, program_id, date, account_code, amount, mode
171
- */
172
- function parseStagingCsv(content: string): { rows: ParsedRow[]; errors: string[] } {
173
- const EXPECTED = ['location', 'master_program', 'program_id', 'date', 'account_code', 'amount', 'mode']
174
- const errors: string[] = []
175
- const lines = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n')
176
-
177
- const nonEmpty = lines.filter(l => l.trim().length > 0)
178
- if (nonEmpty.length < 2) {
179
- return { rows: [], errors: ['CSV has no data rows'] }
180
- }
181
-
182
- const headers = parseCsvLine(nonEmpty[0]).map(h => h.toLowerCase())
183
- const headersMatch =
184
- headers.length === EXPECTED.length &&
185
- headers.every((h, i) => h === EXPECTED[i])
186
-
187
- if (!headersMatch) {
188
- errors.push(`Header mismatch. Expected: [${EXPECTED.join(', ')}]. Got: [${headers.join(', ')}]`)
189
- return { rows: [], errors }
190
- }
191
-
192
- const rows: ParsedRow[] = []
193
- for (let i = 1; i < nonEmpty.length; i++) {
194
- const values = parseCsvLine(nonEmpty[i])
195
- if (values.length !== EXPECTED.length) {
196
- errors.push(`Line ${i + 1}: expected ${EXPECTED.length} columns, got ${values.length}`)
197
- continue
198
- }
199
- const [location, master_program, program_id, date, account_code, amount, mode] = values
200
- rows.push({ location, master_program, program_id, date, account_code, amount, mode })
201
- }
202
-
203
- return { rows, errors }
204
- }
205
-
206
- // ---------------------------------------------------------------------------
207
- // XLSM parsing — single "Data Entry" sheet (wide → long pivot)
208
- // ---------------------------------------------------------------------------
209
-
210
- async function parseDataEntrySheet(
211
- workbook: ExcelJS.Workbook,
212
- sheetName: string,
213
- masterProgramMap: Map<string, string>,
214
- warnings: string[],
215
- ): Promise<ParsedRow[]> {
216
- const sheet = workbook.getWorksheet(sheetName)
217
- if (!sheet) {
218
- warnings.push(`Sheet "${sheetName}" not found`)
219
- return []
220
- }
221
-
222
- // Collect all rows as plain objects
223
- const jsonRows: Record<string, unknown>[] = []
224
- let headers: string[] = []
225
- let headerRowIndex = -1
226
-
227
- sheet.eachRow((row, rowNumber) => {
228
- if (headerRowIndex === -1) {
229
- // First row is headers
230
- const values = row.values as unknown[]
231
- // ExcelJS row.values[0] is undefined (1-indexed), slice from 1
232
- const rawHeaders = (values as unknown[]).slice(1).map(v => (v != null ? String(v).trim() : ''))
233
- if (rawHeaders.some(h => h === 'Master Program')) {
234
- headers = rawHeaders
235
- headerRowIndex = rowNumber
236
- }
237
- return
238
- }
239
-
240
- const obj: Record<string, unknown> = {}
241
- const vals = row.values as unknown[]
242
- for (let i = 0; i < headers.length; i++) {
243
- const cellVal = vals[i + 1]
244
- // ExcelJS rich text
245
- if (cellVal && typeof cellVal === 'object' && 'richText' in (cellVal as object)) {
246
- obj[headers[i]] = (cellVal as { richText: Array<{ text: string }> }).richText
247
- .map(r => r.text)
248
- .join('')
249
- } else {
250
- obj[headers[i]] = cellVal ?? ''
251
- }
252
- }
253
- jsonRows.push(obj)
254
- })
255
-
256
- if (headers.length === 0) {
257
- warnings.push(`Sheet "${sheetName}": could not find header row`)
258
- return []
259
- }
260
-
261
- // Identify account code columns (all non-meta columns)
262
- const accountCodes = headers.filter(h => h && !META_COLUMNS.has(h))
263
- const rows: ParsedRow[] = []
264
-
265
- for (const row of jsonRows) {
266
- const masterProgram = String(row['Master Program'] ?? '').trim()
267
- const programId = String(row['Program ID'] ?? '').trim()
268
- const rawDate = row['Date (Upload)']
269
-
270
- if (!masterProgram) continue
271
-
272
- const isoDate = normaliseDate(rawDate)
273
- if (!isoDate) {
274
- warnings.push(`Skipped row: unparseable date "${rawDate}" for program "${masterProgram}"`)
275
- continue
276
- }
277
-
278
- // Resolve location (client name) from master program map
279
- const location = masterProgramMap.get(masterProgram)
280
- if (!location) {
281
- warnings.push(`No client mapping for master program: "${masterProgram}"`)
282
- continue
283
- }
284
-
285
- for (const accountCode of accountCodes) {
286
- const rawAmount = row[accountCode]
287
- if (rawAmount === null || rawAmount === undefined || rawAmount === '' || rawAmount === 0) continue
288
-
289
- const numericAmount = typeof rawAmount === 'number' ? rawAmount : parseFloat(String(rawAmount))
290
- if (isNaN(numericAmount) || numericAmount === 0) continue
291
-
292
- rows.push({
293
- location,
294
- master_program: masterProgram,
295
- program_id: programId,
296
- date: isoDate,
297
- account_code: accountCode,
298
- amount: String(numericAmount),
299
- mode: 'Actual',
300
- })
301
- }
302
- }
303
-
304
- return rows
305
- }
306
-
307
- // ---------------------------------------------------------------------------
308
- // XLSM parsing — multi-sheet (one tab per month)
309
- // ---------------------------------------------------------------------------
310
-
311
- async function parseMultiSheetWorkbook(
312
- workbook: ExcelJS.Workbook,
313
- masterProgramMap: Map<string, string>,
314
- filterMonths: string[] | undefined,
315
- warnings: string[],
316
- ): Promise<ParsedRow[]> {
317
- const allRows: ParsedRow[] = []
318
- const monthSheets = workbook.worksheets
319
- .map(ws => ws.name.trim())
320
- .filter(name => MONTH_NAMES.has(name))
321
-
322
- const sheetsToProcess = filterMonths
323
- ? monthSheets.filter(s => {
324
- // filterMonths may be YYYY-MM strings; we match the abbreviated month name part
325
- return filterMonths.some(m => {
326
- const parts = m.split('-')
327
- const monthNum = parts[1] ? parseInt(parts[1], 10) : 0
328
- const abbr = ['', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
329
- 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][monthNum] ?? ''
330
- return abbr === s
331
- })
332
- })
333
- : monthSheets
334
-
335
- for (const sheetName of sheetsToProcess) {
336
- const sheetRows = await parseDataEntrySheet(workbook, sheetName, masterProgramMap, warnings)
337
- allRows.push(...sheetRows)
338
- }
339
-
340
- return allRows
341
- }
342
-
343
- // ---------------------------------------------------------------------------
344
- // Dimension lookup helpers (FK resolution against ReturnPro Supabase)
345
- // ---------------------------------------------------------------------------
346
-
347
- function chunkArray<T>(arr: T[], size: number): T[][] {
348
- const out: T[][] = []
349
- for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size))
350
- return out
351
- }
352
-
353
- async function buildMasterProgramToClientMap(): Promise<Map<string, string>> {
354
- const sb = getSupabase('returnpro')
355
- const map = new Map<string, string>()
356
- let from = 0
357
- const PAGE = 1000
358
-
359
- while (true) {
360
- const { data, error } = await sb
361
- .from('dim_master_program')
362
- .select('master_name,dim_client(client_name)')
363
- .range(from, from + PAGE - 1)
364
-
365
- if (error) throw new Error(`Fetch dim_master_program failed: ${error.message}`)
366
- if (!data || data.length === 0) break
367
-
368
- for (const row of (data as unknown) as Array<{ master_name: string; dim_client: { client_name: string } | null }>) {
369
- map.set(row.master_name, row.dim_client?.client_name ?? '- None -')
370
- }
371
-
372
- if (data.length < PAGE) break
373
- from += PAGE
374
- }
375
-
376
- return map
377
- }
378
-
379
- async function buildAccountLookupMap(codes: string[]): Promise<{
380
- accountMap: Map<string, number>
381
- signMultiplierMap: Map<string, number>
382
- }> {
383
- const sb = getSupabase('returnpro')
384
- const unique = Array.from(new Set(codes.filter(Boolean)))
385
- const accountMap = new Map<string, number>()
386
- const signMultiplierMap = new Map<string, number>()
387
-
388
- for (const batch of chunkArray(unique, 100)) {
389
- const { data, error } = await sb
390
- .from('dim_account')
391
- .select('account_id,account_code,sign_multiplier')
392
- .in('account_code', batch)
393
-
394
- if (error) continue
395
- for (const row of (data ?? []) as Array<{ account_id: number; account_code: string; sign_multiplier: number | null }>) {
396
- accountMap.set(row.account_code, row.account_id)
397
- signMultiplierMap.set(row.account_code, row.sign_multiplier ?? 1)
398
- }
399
- }
400
-
401
- return { accountMap, signMultiplierMap }
402
- }
403
-
404
- async function buildClientLookupMap(locations: string[]): Promise<Map<string, number>> {
405
- const sb = getSupabase('returnpro')
406
- const unique = Array.from(new Set(locations.filter(Boolean)))
407
- const map = new Map<string, number>()
408
-
409
- for (const batch of chunkArray(unique, 100)) {
410
- const { data, error } = await sb
411
- .from('dim_client')
412
- .select('client_id,client_name')
413
- .in('client_name', batch)
414
-
415
- if (error) continue
416
- for (const row of (data ?? []) as Array<{ client_id: number; client_name: string }>) {
417
- map.set(row.client_name, row.client_id)
418
- }
419
- }
420
-
421
- return map
422
- }
423
-
424
- async function buildMasterProgramIdMap(
425
- programs: Array<{ master_program: string; client_id: number }>,
426
- ): Promise<Map<string, number>> {
427
- const sb = getSupabase('returnpro')
428
- const map = new Map<string, number>()
429
-
430
- // Group by client_id
431
- const byClient = new Map<number, string[]>()
432
- for (const p of programs) {
433
- if (!p.master_program || !p.client_id) continue
434
- if (!byClient.has(p.client_id)) byClient.set(p.client_id, [])
435
- byClient.get(p.client_id)!.push(p.master_program)
436
- }
437
-
438
- for (const [clientId, names] of byClient.entries()) {
439
- for (const batch of chunkArray(Array.from(new Set(names)), 100)) {
440
- const { data, error } = await sb
441
- .from('dim_master_program')
442
- .select('master_program_id,master_name,client_id')
443
- .in('master_name', batch)
444
- .eq('client_id', clientId)
445
-
446
- if (error) continue
447
- for (const row of (data ?? []) as Array<{ master_program_id: number; master_name: string; client_id: number }>) {
448
- map.set(`${row.client_id}|${row.master_name}`, row.master_program_id)
449
- }
450
- }
451
- }
452
-
453
- return map
454
- }
455
-
456
- async function buildProgramIdMap(codes: string[]): Promise<Map<string, number>> {
457
- const sb = getSupabase('returnpro')
458
- const unique = Array.from(new Set(codes.filter(Boolean)))
459
- const map = new Map<string, number>()
460
-
461
- for (const batch of chunkArray(unique, 100)) {
462
- const { data, error } = await sb
463
- .from('dim_program_id')
464
- .select('program_id_key,program_code')
465
- .in('program_code', batch)
466
-
467
- if (error) continue
468
- for (const row of (data ?? []) as Array<{ program_id_key: number; program_code: string }>) {
469
- map.set(row.program_code, row.program_id_key)
470
- }
471
- }
472
-
473
- return map
474
- }
475
-
476
- // ---------------------------------------------------------------------------
477
- // Insert helpers
478
- // ---------------------------------------------------------------------------
479
-
480
- async function insertBatch(rows: StagingRow[]): Promise<number> {
481
- const sb = getSupabase('returnpro')
482
- let inserted = 0
483
-
484
- for (const batch of chunkArray(rows, CHUNK_SIZE)) {
485
- const { data, error } = await sb
486
- .from('stg_financials_raw')
487
- .insert(batch)
488
- .select('raw_id')
489
-
490
- if (error) throw new Error(`Insert batch failed: ${error.message}`)
491
- inserted += data?.length ?? batch.length
492
- }
493
-
494
- return inserted
495
- }
496
-
497
- // ---------------------------------------------------------------------------
498
- // Main export
499
- // ---------------------------------------------------------------------------
500
-
501
- /**
502
- * Upload a NetSuite XLSM or staging CSV to stg_financials_raw.
503
- *
504
- * Supports two XLSM formats:
505
- * 1. Single "Data Entry" sheet (wide format — account codes as columns)
506
- * 2. Multi-sheet (≥3 month-named tabs, each a "Data Entry"-style sheet)
507
- *
508
- * For CSVs, expects the staging long format:
509
- * location, master_program, program_id, date, account_code, amount, mode
510
- *
511
- * FK columns (account_id, client_id, master_program_id, program_id_key) are
512
- * resolved from dim_* tables and stamped on each row before insert.
513
- *
514
- * `stg_financials_raw.amount` is stored as TEXT per DB schema.
515
- *
516
- * @param filePath Absolute path to .xlsm, .xlsx, or .csv file
517
- * @param userId User ID to associate with this upload (stored for audit)
518
- * @param options Optional: `months` array of YYYY-MM strings to filter which
519
- * month tabs to process (multi-sheet XLSM only)
520
- */
521
- export async function processNetSuiteUpload(
522
- filePath: string,
523
- userId: string,
524
- options?: { months?: string[] },
525
- ): Promise<NetSuiteUploadResult> {
526
- const fileName = basename(filePath)
527
- const ext = extname(filePath).toLowerCase()
528
- const loadedAt = new Date().toISOString()
529
- const warnings: string[] = []
530
-
531
- if (!userId) {
532
- return {
533
- fileName, loadedAt, inserted: 0, monthsCovered: [], warnings,
534
- error: 'userId is required',
535
- }
536
- }
537
-
538
- let parsedRows: ParsedRow[] = []
539
-
540
- try {
541
- // ------------------------------------------------------------------
542
- // 1. Parse the file
543
- // ------------------------------------------------------------------
544
- if (ext === '.csv') {
545
- const content = readFileSync(filePath, 'utf-8')
546
- const { rows, errors } = parseStagingCsv(content)
547
- if (errors.length > 0 && rows.length === 0) {
548
- return { fileName, loadedAt, inserted: 0, monthsCovered: [], warnings, error: errors.join('; ') }
549
- }
550
- warnings.push(...errors)
551
- parsedRows = rows
552
- } else if (ext === '.xlsm' || ext === '.xlsx') {
553
- // Load workbook with ExcelJS
554
- const workbook = new ExcelJS.Workbook()
555
- const buffer = readFileSync(filePath)
556
-
557
- // ExcelJS reads XLSM via xlsx extension (it is a zip-based format)
558
- // Convert Node Buffer → ArrayBuffer so ExcelJS types are satisfied
559
- const arrayBuffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer
560
- await workbook.xlsx.load(arrayBuffer)
561
-
562
- const sheetNames = workbook.worksheets.map(ws => ws.name)
563
- const isMultiSheet = hasMonthlySheets(sheetNames)
564
-
565
- // Fetch master program → client name map once (needed for both paths)
566
- const masterProgramClientMap = await buildMasterProgramToClientMap()
567
-
568
- if (isMultiSheet) {
569
- parsedRows = await parseMultiSheetWorkbook(
570
- workbook, masterProgramClientMap, options?.months, warnings,
571
- )
572
- } else {
573
- // Try "Data Entry" sheet first, then first sheet
574
- const sheetName = sheetNames.find(n => n === 'Data Entry') ?? sheetNames[0]
575
- if (!sheetName) {
576
- return { fileName, loadedAt, inserted: 0, monthsCovered: [], warnings, error: 'Workbook has no sheets' }
577
- }
578
- parsedRows = await parseDataEntrySheet(workbook, sheetName, masterProgramClientMap, warnings)
579
- }
580
- } else {
581
- return {
582
- fileName, loadedAt, inserted: 0, monthsCovered: [], warnings,
583
- error: `Unsupported file extension: ${ext}. Expected .xlsm, .xlsx, or .csv`,
584
- }
585
- }
586
-
587
- if (parsedRows.length === 0) {
588
- return {
589
- fileName, loadedAt, inserted: 0, monthsCovered: [], warnings,
590
- error: 'No rows parsed from file',
591
- }
592
- }
593
-
594
- // ------------------------------------------------------------------
595
- // 2. Filter to requested months if provided (CSV path doesn't filter above)
596
- // ------------------------------------------------------------------
597
- if (options?.months && options.months.length > 0) {
598
- const monthSet = new Set(options.months)
599
- parsedRows = parsedRows.filter(r => {
600
- const m = r.date ? r.date.substring(0, 7) : null
601
- return m ? monthSet.has(m) : false
602
- })
603
- }
604
-
605
- // ------------------------------------------------------------------
606
- // 3. Resolve FK dimension lookups
607
- // ------------------------------------------------------------------
608
- const accountCodes = parsedRows.map(r => r.account_code).filter(Boolean)
609
- const locations = parsedRows.map(r => r.location).filter(Boolean)
610
- const programCodes = parsedRows.map(r => r.program_id).filter(Boolean)
611
-
612
- const [
613
- { accountMap, signMultiplierMap },
614
- clientMap,
615
- programIdMap,
616
- ] = await Promise.all([
617
- buildAccountLookupMap(accountCodes),
618
- buildClientLookupMap(locations),
619
- buildProgramIdMap(programCodes),
620
- ])
621
-
622
- // Master program lookup requires client_id — do after clientMap is ready
623
- const masterProgramInputs: Array<{ master_program: string; client_id: number }> = []
624
- for (const row of parsedRows) {
625
- const clientId = clientMap.get(row.location)
626
- if (clientId && row.master_program) {
627
- masterProgramInputs.push({ master_program: row.master_program, client_id: clientId })
628
- }
629
- }
630
- const masterProgramIdMap = await buildMasterProgramIdMap(masterProgramInputs)
631
-
632
- // ------------------------------------------------------------------
633
- // 4. Stamp rows with FK columns and apply sign convention
634
- // ------------------------------------------------------------------
635
- let signFlippedCount = 0
636
- const stamped: StagingRow[] = parsedRows.map(row => {
637
- const accountId = row.account_code ? accountMap.get(row.account_code) ?? null : null
638
- const clientId = row.location ? clientMap.get(row.location) ?? null : null
639
- const masterProgramId =
640
- row.master_program && clientId
641
- ? masterProgramIdMap.get(`${clientId}|${row.master_program}`) ?? null
642
- : null
643
- const programIdKey = row.program_id ? programIdMap.get(row.program_id) ?? null : null
644
-
645
- // Revenue accounts (sign_multiplier = -1) have their sign flipped
646
- const signMultiplier = row.account_code ? signMultiplierMap.get(row.account_code) ?? 1 : 1
647
- let amount = row.amount
648
- if (signMultiplier === -1) {
649
- const num = parseFloat(row.amount)
650
- if (!isNaN(num)) {
651
- amount = String(num * -1)
652
- signFlippedCount++
653
- }
654
- }
655
-
656
- return {
657
- source_file_name: fileName,
658
- loaded_at: loadedAt,
659
- location: row.location,
660
- master_program: row.master_program,
661
- program_code: row.program_id,
662
- date: row.date,
663
- account_code: row.account_code,
664
- amount, // TEXT — stored as string
665
- mode: row.mode,
666
- account_id: accountId,
667
- client_id: clientId,
668
- master_program_id: masterProgramId,
669
- program_id_key: programIdKey,
670
- }
671
- })
672
-
673
- if (signFlippedCount > 0) {
674
- warnings.push(`Sign convention applied: ${signFlippedCount} revenue account rows flipped`)
675
- }
676
-
677
- // ------------------------------------------------------------------
678
- // 5. Insert into stg_financials_raw
679
- // ------------------------------------------------------------------
680
- const inserted = await insertBatch(stamped)
681
-
682
- // ------------------------------------------------------------------
683
- // 6. Collect distinct months covered
684
- // ------------------------------------------------------------------
685
- const monthSet = new Set<string>()
686
- for (const row of stamped) {
687
- if (row.date) monthSet.add(row.date.substring(0, 7))
688
- }
689
- const monthsCovered = [...monthSet].sort()
690
-
691
- return { fileName, loadedAt, inserted, monthsCovered, warnings }
692
- } catch (err) {
693
- const error = err instanceof Error ? err.message : String(err)
694
- return { fileName, loadedAt, inserted: 0, monthsCovered: [], warnings, error }
695
- }
696
- }