optimal-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +175 -0
  2. package/dist/bin/optimal.d.ts +2 -0
  3. package/dist/bin/optimal.js +995 -0
  4. package/dist/lib/budget/projections.d.ts +115 -0
  5. package/dist/lib/budget/projections.js +384 -0
  6. package/dist/lib/budget/scenarios.d.ts +93 -0
  7. package/dist/lib/budget/scenarios.js +214 -0
  8. package/dist/lib/cms/publish-blog.d.ts +62 -0
  9. package/dist/lib/cms/publish-blog.js +74 -0
  10. package/dist/lib/cms/strapi-client.d.ts +123 -0
  11. package/dist/lib/cms/strapi-client.js +213 -0
  12. package/dist/lib/config.d.ts +55 -0
  13. package/dist/lib/config.js +206 -0
  14. package/dist/lib/infra/deploy.d.ts +29 -0
  15. package/dist/lib/infra/deploy.js +58 -0
  16. package/dist/lib/infra/migrate.d.ts +34 -0
  17. package/dist/lib/infra/migrate.js +103 -0
  18. package/dist/lib/kanban.d.ts +46 -0
  19. package/dist/lib/kanban.js +118 -0
  20. package/dist/lib/newsletter/distribute.d.ts +52 -0
  21. package/dist/lib/newsletter/distribute.js +193 -0
  22. package/dist/lib/newsletter/generate-insurance.d.ts +42 -0
  23. package/dist/lib/newsletter/generate-insurance.js +36 -0
  24. package/dist/lib/newsletter/generate.d.ts +104 -0
  25. package/dist/lib/newsletter/generate.js +571 -0
  26. package/dist/lib/returnpro/anomalies.d.ts +64 -0
  27. package/dist/lib/returnpro/anomalies.js +166 -0
  28. package/dist/lib/returnpro/audit.d.ts +32 -0
  29. package/dist/lib/returnpro/audit.js +147 -0
  30. package/dist/lib/returnpro/diagnose.d.ts +52 -0
  31. package/dist/lib/returnpro/diagnose.js +281 -0
  32. package/dist/lib/returnpro/kpis.d.ts +32 -0
  33. package/dist/lib/returnpro/kpis.js +192 -0
  34. package/dist/lib/returnpro/templates.d.ts +48 -0
  35. package/dist/lib/returnpro/templates.js +229 -0
  36. package/dist/lib/returnpro/upload-income.d.ts +25 -0
  37. package/dist/lib/returnpro/upload-income.js +235 -0
  38. package/dist/lib/returnpro/upload-netsuite.d.ts +37 -0
  39. package/dist/lib/returnpro/upload-netsuite.js +566 -0
  40. package/dist/lib/returnpro/upload-r1.d.ts +48 -0
  41. package/dist/lib/returnpro/upload-r1.js +398 -0
  42. package/dist/lib/social/post-generator.d.ts +83 -0
  43. package/dist/lib/social/post-generator.js +333 -0
  44. package/dist/lib/social/publish.d.ts +66 -0
  45. package/dist/lib/social/publish.js +226 -0
  46. package/dist/lib/social/scraper.d.ts +67 -0
  47. package/dist/lib/social/scraper.js +361 -0
  48. package/dist/lib/supabase.d.ts +4 -0
  49. package/dist/lib/supabase.js +20 -0
  50. package/dist/lib/transactions/delete-batch.d.ts +60 -0
  51. package/dist/lib/transactions/delete-batch.js +203 -0
  52. package/dist/lib/transactions/ingest.d.ts +43 -0
  53. package/dist/lib/transactions/ingest.js +555 -0
  54. package/dist/lib/transactions/stamp.d.ts +51 -0
  55. package/dist/lib/transactions/stamp.js +524 -0
  56. package/package.json +50 -0
@@ -0,0 +1,566 @@
1
+ import { readFileSync } from 'fs';
2
+ import { basename, extname } from 'path';
3
+ import ExcelJS from 'exceljs';
4
+ import { getSupabase } from '../supabase.js';
5
+ // ---------------------------------------------------------------------------
6
+ // Constants
7
+ // ---------------------------------------------------------------------------
8
+ const CHUNK_SIZE = 500;
9
+ /**
10
+ * Month names used to detect multi-sheet (monthly tab) workbooks.
11
+ * Matches the fiscal calendar used in dashboard-returnpro (Apr=start).
12
+ */
13
+ const MONTH_NAMES = new Set([
14
+ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
15
+ 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
16
+ ]);
17
+ /**
18
+ * NetSuite "Data Entry" sheet meta columns — everything else is an account code.
19
+ */
20
+ const META_COLUMNS = new Set([
21
+ 'Master Program',
22
+ 'Program ID',
23
+ 'Date (Upload)',
24
+ 'Date (Solution7)',
25
+ ]);
26
+ // ---------------------------------------------------------------------------
27
+ // Excel serial date conversion
28
+ // ---------------------------------------------------------------------------
29
+ /**
30
+ * Convert an Excel serial date number (e.g. 46023) to ISO YYYY-MM-DD.
31
+ * Excel epoch is 1899-12-30 (accounting for the Lotus 1-2-3 leap-year bug).
32
+ */
33
+ function excelSerialToIso(serial) {
34
+ const msPerDay = 86400000;
35
+ const excelEpoch = new Date(Date.UTC(1899, 11, 30));
36
+ const date = new Date(excelEpoch.getTime() + serial * msPerDay);
37
+ return date.toISOString().slice(0, 10);
38
+ }
39
+ /**
40
+ * Normalise a date value from an XLSM cell.
41
+ * Accepts:
42
+ * - JS Date objects (ExcelJS parses dates for typed cells)
43
+ * - Excel serial numbers (numeric)
44
+ * - ISO string / "Mon YYYY" partial strings
45
+ * Returns ISO YYYY-MM-DD, or null if unparseable.
46
+ */
47
+ function normaliseDate(raw) {
48
+ if (raw == null || raw === '')
49
+ return null;
50
+ // ExcelJS may return a JS Date for date-typed cells
51
+ if (raw instanceof Date) {
52
+ if (isNaN(raw.getTime()))
53
+ return null;
54
+ return raw.toISOString().slice(0, 10);
55
+ }
56
+ if (typeof raw === 'number') {
57
+ // Excel serial — must be positive and plausible (> 1 = 1900-01-01)
58
+ if (raw > 1)
59
+ return excelSerialToIso(raw);
60
+ return null;
61
+ }
62
+ if (typeof raw === 'string') {
63
+ const s = raw.trim();
64
+ // Already ISO
65
+ if (/^\d{4}-\d{2}-\d{2}$/.test(s))
66
+ return s;
67
+ // Try JS Date parse for other formats
68
+ const d = new Date(s);
69
+ if (!isNaN(d.getTime()))
70
+ return d.toISOString().slice(0, 10);
71
+ }
72
+ return null;
73
+ }
74
+ // ---------------------------------------------------------------------------
75
+ // Sheet detection
76
+ // ---------------------------------------------------------------------------
77
+ /**
78
+ * Returns true if the workbook has ≥3 sheets whose names match abbreviated
79
+ * month names (Jan, Feb, …, Dec). This is the same heuristic used in
80
+ * dashboard-returnpro's WesImporter.tsx → hasMonthlySheets().
81
+ */
82
+ function hasMonthlySheets(sheetNames) {
83
+ const found = sheetNames.filter(name => MONTH_NAMES.has(name.trim()));
84
+ return found.length >= 3;
85
+ }
86
+ // ---------------------------------------------------------------------------
87
+ // CSV parsing
88
+ // ---------------------------------------------------------------------------
89
+ /**
90
+ * Parse a single CSV line, respecting quoted fields.
91
+ */
92
+ function parseCsvLine(line) {
93
+ const fields = [];
94
+ let current = '';
95
+ let inQuotes = false;
96
+ for (let i = 0; i < line.length; i++) {
97
+ const ch = line[i];
98
+ if (ch === '"') {
99
+ if (!inQuotes) {
100
+ inQuotes = true;
101
+ continue;
102
+ }
103
+ if (line[i + 1] === '"') {
104
+ current += '"';
105
+ i++;
106
+ continue;
107
+ }
108
+ inQuotes = false;
109
+ continue;
110
+ }
111
+ if (ch === ',' && !inQuotes) {
112
+ fields.push(current.trim());
113
+ current = '';
114
+ continue;
115
+ }
116
+ current += ch;
117
+ }
118
+ fields.push(current.trim());
119
+ return fields;
120
+ }
121
+ /**
122
+ * Parse a staging CSV file (long format).
123
+ * Expected headers: location, master_program, program_id, date, account_code, amount, mode
124
+ */
125
+ function parseStagingCsv(content) {
126
+ const EXPECTED = ['location', 'master_program', 'program_id', 'date', 'account_code', 'amount', 'mode'];
127
+ const errors = [];
128
+ const lines = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n');
129
+ const nonEmpty = lines.filter(l => l.trim().length > 0);
130
+ if (nonEmpty.length < 2) {
131
+ return { rows: [], errors: ['CSV has no data rows'] };
132
+ }
133
+ const headers = parseCsvLine(nonEmpty[0]).map(h => h.toLowerCase());
134
+ const headersMatch = headers.length === EXPECTED.length &&
135
+ headers.every((h, i) => h === EXPECTED[i]);
136
+ if (!headersMatch) {
137
+ errors.push(`Header mismatch. Expected: [${EXPECTED.join(', ')}]. Got: [${headers.join(', ')}]`);
138
+ return { rows: [], errors };
139
+ }
140
+ const rows = [];
141
+ for (let i = 1; i < nonEmpty.length; i++) {
142
+ const values = parseCsvLine(nonEmpty[i]);
143
+ if (values.length !== EXPECTED.length) {
144
+ errors.push(`Line ${i + 1}: expected ${EXPECTED.length} columns, got ${values.length}`);
145
+ continue;
146
+ }
147
+ const [location, master_program, program_id, date, account_code, amount, mode] = values;
148
+ rows.push({ location, master_program, program_id, date, account_code, amount, mode });
149
+ }
150
+ return { rows, errors };
151
+ }
152
+ // ---------------------------------------------------------------------------
153
+ // XLSM parsing — single "Data Entry" sheet (wide → long pivot)
154
+ // ---------------------------------------------------------------------------
155
+ async function parseDataEntrySheet(workbook, sheetName, masterProgramMap, warnings) {
156
+ const sheet = workbook.getWorksheet(sheetName);
157
+ if (!sheet) {
158
+ warnings.push(`Sheet "${sheetName}" not found`);
159
+ return [];
160
+ }
161
+ // Collect all rows as plain objects
162
+ const jsonRows = [];
163
+ let headers = [];
164
+ let headerRowIndex = -1;
165
+ sheet.eachRow((row, rowNumber) => {
166
+ if (headerRowIndex === -1) {
167
+ // First row is headers
168
+ const values = row.values;
169
+ // ExcelJS row.values[0] is undefined (1-indexed), slice from 1
170
+ const rawHeaders = values.slice(1).map(v => (v != null ? String(v).trim() : ''));
171
+ if (rawHeaders.some(h => h === 'Master Program')) {
172
+ headers = rawHeaders;
173
+ headerRowIndex = rowNumber;
174
+ }
175
+ return;
176
+ }
177
+ const obj = {};
178
+ const vals = row.values;
179
+ for (let i = 0; i < headers.length; i++) {
180
+ const cellVal = vals[i + 1];
181
+ // ExcelJS rich text
182
+ if (cellVal && typeof cellVal === 'object' && 'richText' in cellVal) {
183
+ obj[headers[i]] = cellVal.richText
184
+ .map(r => r.text)
185
+ .join('');
186
+ }
187
+ else {
188
+ obj[headers[i]] = cellVal ?? '';
189
+ }
190
+ }
191
+ jsonRows.push(obj);
192
+ });
193
+ if (headers.length === 0) {
194
+ warnings.push(`Sheet "${sheetName}": could not find header row`);
195
+ return [];
196
+ }
197
+ // Identify account code columns (all non-meta columns)
198
+ const accountCodes = headers.filter(h => h && !META_COLUMNS.has(h));
199
+ const rows = [];
200
+ for (const row of jsonRows) {
201
+ const masterProgram = String(row['Master Program'] ?? '').trim();
202
+ const programId = String(row['Program ID'] ?? '').trim();
203
+ const rawDate = row['Date (Upload)'];
204
+ if (!masterProgram)
205
+ continue;
206
+ const isoDate = normaliseDate(rawDate);
207
+ if (!isoDate) {
208
+ warnings.push(`Skipped row: unparseable date "${rawDate}" for program "${masterProgram}"`);
209
+ continue;
210
+ }
211
+ // Resolve location (client name) from master program map
212
+ const location = masterProgramMap.get(masterProgram);
213
+ if (!location) {
214
+ warnings.push(`No client mapping for master program: "${masterProgram}"`);
215
+ continue;
216
+ }
217
+ for (const accountCode of accountCodes) {
218
+ const rawAmount = row[accountCode];
219
+ if (rawAmount === null || rawAmount === undefined || rawAmount === '' || rawAmount === 0)
220
+ continue;
221
+ const numericAmount = typeof rawAmount === 'number' ? rawAmount : parseFloat(String(rawAmount));
222
+ if (isNaN(numericAmount) || numericAmount === 0)
223
+ continue;
224
+ rows.push({
225
+ location,
226
+ master_program: masterProgram,
227
+ program_id: programId,
228
+ date: isoDate,
229
+ account_code: accountCode,
230
+ amount: String(numericAmount),
231
+ mode: 'Actual',
232
+ });
233
+ }
234
+ }
235
+ return rows;
236
+ }
237
+ // ---------------------------------------------------------------------------
238
+ // XLSM parsing — multi-sheet (one tab per month)
239
+ // ---------------------------------------------------------------------------
240
+ async function parseMultiSheetWorkbook(workbook, masterProgramMap, filterMonths, warnings) {
241
+ const allRows = [];
242
+ const monthSheets = workbook.worksheets
243
+ .map(ws => ws.name.trim())
244
+ .filter(name => MONTH_NAMES.has(name));
245
+ const sheetsToProcess = filterMonths
246
+ ? monthSheets.filter(s => {
247
+ // filterMonths may be YYYY-MM strings; we match the abbreviated month name part
248
+ return filterMonths.some(m => {
249
+ const parts = m.split('-');
250
+ const monthNum = parts[1] ? parseInt(parts[1], 10) : 0;
251
+ const abbr = ['', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
252
+ 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][monthNum] ?? '';
253
+ return abbr === s;
254
+ });
255
+ })
256
+ : monthSheets;
257
+ for (const sheetName of sheetsToProcess) {
258
+ const sheetRows = await parseDataEntrySheet(workbook, sheetName, masterProgramMap, warnings);
259
+ allRows.push(...sheetRows);
260
+ }
261
+ return allRows;
262
+ }
263
+ // ---------------------------------------------------------------------------
264
+ // Dimension lookup helpers (FK resolution against ReturnPro Supabase)
265
+ // ---------------------------------------------------------------------------
266
+ function chunkArray(arr, size) {
267
+ const out = [];
268
+ for (let i = 0; i < arr.length; i += size)
269
+ out.push(arr.slice(i, i + size));
270
+ return out;
271
+ }
272
+ async function buildMasterProgramToClientMap() {
273
+ const sb = getSupabase('returnpro');
274
+ const map = new Map();
275
+ let from = 0;
276
+ const PAGE = 1000;
277
+ while (true) {
278
+ const { data, error } = await sb
279
+ .from('dim_master_program')
280
+ .select('master_name,dim_client(client_name)')
281
+ .range(from, from + PAGE - 1);
282
+ if (error)
283
+ throw new Error(`Fetch dim_master_program failed: ${error.message}`);
284
+ if (!data || data.length === 0)
285
+ break;
286
+ for (const row of data) {
287
+ map.set(row.master_name, row.dim_client?.client_name ?? '- None -');
288
+ }
289
+ if (data.length < PAGE)
290
+ break;
291
+ from += PAGE;
292
+ }
293
+ return map;
294
+ }
295
+ async function buildAccountLookupMap(codes) {
296
+ const sb = getSupabase('returnpro');
297
+ const unique = Array.from(new Set(codes.filter(Boolean)));
298
+ const accountMap = new Map();
299
+ const signMultiplierMap = new Map();
300
+ for (const batch of chunkArray(unique, 100)) {
301
+ const { data, error } = await sb
302
+ .from('dim_account')
303
+ .select('account_id,account_code,sign_multiplier')
304
+ .in('account_code', batch);
305
+ if (error)
306
+ continue;
307
+ for (const row of (data ?? [])) {
308
+ accountMap.set(row.account_code, row.account_id);
309
+ signMultiplierMap.set(row.account_code, row.sign_multiplier ?? 1);
310
+ }
311
+ }
312
+ return { accountMap, signMultiplierMap };
313
+ }
314
+ async function buildClientLookupMap(locations) {
315
+ const sb = getSupabase('returnpro');
316
+ const unique = Array.from(new Set(locations.filter(Boolean)));
317
+ const map = new Map();
318
+ for (const batch of chunkArray(unique, 100)) {
319
+ const { data, error } = await sb
320
+ .from('dim_client')
321
+ .select('client_id,client_name')
322
+ .in('client_name', batch);
323
+ if (error)
324
+ continue;
325
+ for (const row of (data ?? [])) {
326
+ map.set(row.client_name, row.client_id);
327
+ }
328
+ }
329
+ return map;
330
+ }
331
+ async function buildMasterProgramIdMap(programs) {
332
+ const sb = getSupabase('returnpro');
333
+ const map = new Map();
334
+ // Group by client_id
335
+ const byClient = new Map();
336
+ for (const p of programs) {
337
+ if (!p.master_program || !p.client_id)
338
+ continue;
339
+ if (!byClient.has(p.client_id))
340
+ byClient.set(p.client_id, []);
341
+ byClient.get(p.client_id).push(p.master_program);
342
+ }
343
+ for (const [clientId, names] of byClient.entries()) {
344
+ for (const batch of chunkArray(Array.from(new Set(names)), 100)) {
345
+ const { data, error } = await sb
346
+ .from('dim_master_program')
347
+ .select('master_program_id,master_name,client_id')
348
+ .in('master_name', batch)
349
+ .eq('client_id', clientId);
350
+ if (error)
351
+ continue;
352
+ for (const row of (data ?? [])) {
353
+ map.set(`${row.client_id}|${row.master_name}`, row.master_program_id);
354
+ }
355
+ }
356
+ }
357
+ return map;
358
+ }
359
+ async function buildProgramIdMap(codes) {
360
+ const sb = getSupabase('returnpro');
361
+ const unique = Array.from(new Set(codes.filter(Boolean)));
362
+ const map = new Map();
363
+ for (const batch of chunkArray(unique, 100)) {
364
+ const { data, error } = await sb
365
+ .from('dim_program_id')
366
+ .select('program_id_key,program_code')
367
+ .in('program_code', batch);
368
+ if (error)
369
+ continue;
370
+ for (const row of (data ?? [])) {
371
+ map.set(row.program_code, row.program_id_key);
372
+ }
373
+ }
374
+ return map;
375
+ }
376
+ // ---------------------------------------------------------------------------
377
+ // Insert helpers
378
+ // ---------------------------------------------------------------------------
379
+ async function insertBatch(rows) {
380
+ const sb = getSupabase('returnpro');
381
+ let inserted = 0;
382
+ for (const batch of chunkArray(rows, CHUNK_SIZE)) {
383
+ const { data, error } = await sb
384
+ .from('stg_financials_raw')
385
+ .insert(batch)
386
+ .select('raw_id');
387
+ if (error)
388
+ throw new Error(`Insert batch failed: ${error.message}`);
389
+ inserted += data?.length ?? batch.length;
390
+ }
391
+ return inserted;
392
+ }
393
+ // ---------------------------------------------------------------------------
394
+ // Main export
395
+ // ---------------------------------------------------------------------------
396
+ /**
397
+ * Upload a NetSuite XLSM or staging CSV to stg_financials_raw.
398
+ *
399
+ * Supports two XLSM formats:
400
+ * 1. Single "Data Entry" sheet (wide format — account codes as columns)
401
+ * 2. Multi-sheet (≥3 month-named tabs, each a "Data Entry"-style sheet)
402
+ *
403
+ * For CSVs, expects the staging long format:
404
+ * location, master_program, program_id, date, account_code, amount, mode
405
+ *
406
+ * FK columns (account_id, client_id, master_program_id, program_id_key) are
407
+ * resolved from dim_* tables and stamped on each row before insert.
408
+ *
409
+ * `stg_financials_raw.amount` is stored as TEXT per DB schema.
410
+ *
411
+ * @param filePath Absolute path to .xlsm, .xlsx, or .csv file
412
+ * @param userId User ID to associate with this upload (stored for audit)
413
+ * @param options Optional: `months` array of YYYY-MM strings to filter which
414
+ * month tabs to process (multi-sheet XLSM only)
415
+ */
416
+ export async function processNetSuiteUpload(filePath, userId, options) {
417
+ const fileName = basename(filePath);
418
+ const ext = extname(filePath).toLowerCase();
419
+ const loadedAt = new Date().toISOString();
420
+ const warnings = [];
421
+ if (!userId) {
422
+ return {
423
+ fileName, loadedAt, inserted: 0, monthsCovered: [], warnings,
424
+ error: 'userId is required',
425
+ };
426
+ }
427
+ let parsedRows = [];
428
+ try {
429
+ // ------------------------------------------------------------------
430
+ // 1. Parse the file
431
+ // ------------------------------------------------------------------
432
+ if (ext === '.csv') {
433
+ const content = readFileSync(filePath, 'utf-8');
434
+ const { rows, errors } = parseStagingCsv(content);
435
+ if (errors.length > 0 && rows.length === 0) {
436
+ return { fileName, loadedAt, inserted: 0, monthsCovered: [], warnings, error: errors.join('; ') };
437
+ }
438
+ warnings.push(...errors);
439
+ parsedRows = rows;
440
+ }
441
+ else if (ext === '.xlsm' || ext === '.xlsx') {
442
+ // Load workbook with ExcelJS
443
+ const workbook = new ExcelJS.Workbook();
444
+ const buffer = readFileSync(filePath);
445
+ // ExcelJS reads XLSM via xlsx extension (it is a zip-based format)
446
+ // Convert Node Buffer → ArrayBuffer so ExcelJS types are satisfied
447
+ const arrayBuffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
448
+ await workbook.xlsx.load(arrayBuffer);
449
+ const sheetNames = workbook.worksheets.map(ws => ws.name);
450
+ const isMultiSheet = hasMonthlySheets(sheetNames);
451
+ // Fetch master program → client name map once (needed for both paths)
452
+ const masterProgramClientMap = await buildMasterProgramToClientMap();
453
+ if (isMultiSheet) {
454
+ parsedRows = await parseMultiSheetWorkbook(workbook, masterProgramClientMap, options?.months, warnings);
455
+ }
456
+ else {
457
+ // Try "Data Entry" sheet first, then first sheet
458
+ const sheetName = sheetNames.find(n => n === 'Data Entry') ?? sheetNames[0];
459
+ if (!sheetName) {
460
+ return { fileName, loadedAt, inserted: 0, monthsCovered: [], warnings, error: 'Workbook has no sheets' };
461
+ }
462
+ parsedRows = await parseDataEntrySheet(workbook, sheetName, masterProgramClientMap, warnings);
463
+ }
464
+ }
465
+ else {
466
+ return {
467
+ fileName, loadedAt, inserted: 0, monthsCovered: [], warnings,
468
+ error: `Unsupported file extension: ${ext}. Expected .xlsm, .xlsx, or .csv`,
469
+ };
470
+ }
471
+ if (parsedRows.length === 0) {
472
+ return {
473
+ fileName, loadedAt, inserted: 0, monthsCovered: [], warnings,
474
+ error: 'No rows parsed from file',
475
+ };
476
+ }
477
+ // ------------------------------------------------------------------
478
+ // 2. Filter to requested months if provided (CSV path doesn't filter above)
479
+ // ------------------------------------------------------------------
480
+ if (options?.months && options.months.length > 0) {
481
+ const monthSet = new Set(options.months);
482
+ parsedRows = parsedRows.filter(r => {
483
+ const m = r.date ? r.date.substring(0, 7) : null;
484
+ return m ? monthSet.has(m) : false;
485
+ });
486
+ }
487
+ // ------------------------------------------------------------------
488
+ // 3. Resolve FK dimension lookups
489
+ // ------------------------------------------------------------------
490
+ const accountCodes = parsedRows.map(r => r.account_code).filter(Boolean);
491
+ const locations = parsedRows.map(r => r.location).filter(Boolean);
492
+ const programCodes = parsedRows.map(r => r.program_id).filter(Boolean);
493
+ const [{ accountMap, signMultiplierMap }, clientMap, programIdMap,] = await Promise.all([
494
+ buildAccountLookupMap(accountCodes),
495
+ buildClientLookupMap(locations),
496
+ buildProgramIdMap(programCodes),
497
+ ]);
498
+ // Master program lookup requires client_id — do after clientMap is ready
499
+ const masterProgramInputs = [];
500
+ for (const row of parsedRows) {
501
+ const clientId = clientMap.get(row.location);
502
+ if (clientId && row.master_program) {
503
+ masterProgramInputs.push({ master_program: row.master_program, client_id: clientId });
504
+ }
505
+ }
506
+ const masterProgramIdMap = await buildMasterProgramIdMap(masterProgramInputs);
507
+ // ------------------------------------------------------------------
508
+ // 4. Stamp rows with FK columns and apply sign convention
509
+ // ------------------------------------------------------------------
510
+ let signFlippedCount = 0;
511
+ const stamped = parsedRows.map(row => {
512
+ const accountId = row.account_code ? accountMap.get(row.account_code) ?? null : null;
513
+ const clientId = row.location ? clientMap.get(row.location) ?? null : null;
514
+ const masterProgramId = row.master_program && clientId
515
+ ? masterProgramIdMap.get(`${clientId}|${row.master_program}`) ?? null
516
+ : null;
517
+ const programIdKey = row.program_id ? programIdMap.get(row.program_id) ?? null : null;
518
+ // Revenue accounts (sign_multiplier = -1) have their sign flipped
519
+ const signMultiplier = row.account_code ? signMultiplierMap.get(row.account_code) ?? 1 : 1;
520
+ let amount = row.amount;
521
+ if (signMultiplier === -1) {
522
+ const num = parseFloat(row.amount);
523
+ if (!isNaN(num)) {
524
+ amount = String(num * -1);
525
+ signFlippedCount++;
526
+ }
527
+ }
528
+ return {
529
+ source_file_name: fileName,
530
+ loaded_at: loadedAt,
531
+ location: row.location,
532
+ master_program: row.master_program,
533
+ program_code: row.program_id,
534
+ date: row.date,
535
+ account_code: row.account_code,
536
+ amount, // TEXT — stored as string
537
+ mode: row.mode,
538
+ account_id: accountId,
539
+ client_id: clientId,
540
+ master_program_id: masterProgramId,
541
+ program_id_key: programIdKey,
542
+ };
543
+ });
544
+ if (signFlippedCount > 0) {
545
+ warnings.push(`Sign convention applied: ${signFlippedCount} revenue account rows flipped`);
546
+ }
547
+ // ------------------------------------------------------------------
548
+ // 5. Insert into stg_financials_raw
549
+ // ------------------------------------------------------------------
550
+ const inserted = await insertBatch(stamped);
551
+ // ------------------------------------------------------------------
552
+ // 6. Collect distinct months covered
553
+ // ------------------------------------------------------------------
554
+ const monthSet = new Set();
555
+ for (const row of stamped) {
556
+ if (row.date)
557
+ monthSet.add(row.date.substring(0, 7));
558
+ }
559
+ const monthsCovered = [...monthSet].sort();
560
+ return { fileName, loadedAt, inserted, monthsCovered, warnings };
561
+ }
562
+ catch (err) {
563
+ const error = err instanceof Error ? err.message : String(err);
564
+ return { fileName, loadedAt, inserted: 0, monthsCovered: [], warnings, error };
565
+ }
566
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * A single row extracted from an R1 XLSX file before aggregation.
3
+ * Columns match the canonical R1 export format documented in dashboard-returnpro.
4
+ */
5
+ export interface R1Row {
6
+ programCode: string;
7
+ masterProgram: string;
8
+ trgid: string;
9
+ locationId: string;
10
+ avgRetail: number | null;
11
+ }
12
+ /**
13
+ * Return value of processR1Upload.
14
+ */
15
+ export interface R1UploadResult {
16
+ /** Source file name (basename) */
17
+ sourceFileName: string;
18
+ /** YYYY-MM-DD date inserted as the `date` column (always the 1st of the given monthYear) */
19
+ date: string;
20
+ /** Total raw rows read from the XLSX (excluding header) */
21
+ totalRowsRead: number;
22
+ /** Number of rows skipped due to missing ProgramName, TRGID, or Master Program Name */
23
+ rowsSkipped: number;
24
+ /** Number of distinct (masterProgram, programCode, location) groups aggregated */
25
+ programGroupsFound: number;
26
+ /** Number of rows actually inserted into stg_financials_raw */
27
+ rowsInserted: number;
28
+ /** Any non-fatal warnings encountered during processing */
29
+ warnings: string[];
30
+ }
31
+ /**
32
+ * Parse an R1 XLSX file, aggregate financial data by program, and insert
33
+ * into the ReturnPro `stg_financials_raw` staging table.
34
+ *
35
+ * Flow:
36
+ * 1. Read and parse the XLSX (first sheet, required columns: ProgramName,
37
+ * Master Program Name, TRGID).
38
+ * 2. Aggregate rows into (masterProgram, programCode, location) groups,
39
+ * counting distinct TRGIDs per group.
40
+ * 3. Look up dim_master_program and dim_program_id to resolve FK columns.
41
+ * 4. Insert into stg_financials_raw in batches of 500.
42
+ *
43
+ * @param filePath Absolute path to the R1 XLSX file on disk.
44
+ * @param userId The user_id to stamp on each inserted row.
45
+ * @param monthYear Target month in "YYYY-MM" format (e.g. "2025-10").
46
+ * Stored as the `date` column as "YYYY-MM-01".
47
+ */
48
+ export declare function processR1Upload(filePath: string, userId: string, monthYear: string): Promise<R1UploadResult>;