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.
- package/agents/.gitkeep +0 -0
- package/agents/content-ops.md +227 -0
- package/agents/financial-ops.md +184 -0
- package/agents/infra-ops.md +206 -0
- package/agents/profiles.json +5 -0
- package/bin/optimal.ts +1731 -0
- package/docs/CLI-REFERENCE.md +361 -0
- package/lib/assets/index.ts +225 -0
- package/lib/assets.ts +124 -0
- package/lib/auth/index.ts +189 -0
- package/lib/board/index.ts +309 -0
- package/lib/board/types.ts +124 -0
- package/lib/bot/claim.ts +43 -0
- package/lib/bot/coordinator.ts +254 -0
- package/lib/bot/heartbeat.ts +37 -0
- package/lib/bot/index.ts +9 -0
- package/lib/bot/protocol.ts +99 -0
- package/lib/bot/reporter.ts +42 -0
- package/lib/bot/skills.ts +81 -0
- package/lib/budget/projections.ts +561 -0
- package/lib/budget/scenarios.ts +312 -0
- package/lib/cms/publish-blog.ts +129 -0
- package/lib/cms/strapi-client.ts +302 -0
- package/lib/config/registry.ts +228 -0
- package/lib/config/schema.ts +58 -0
- package/lib/config.ts +247 -0
- package/lib/errors.ts +129 -0
- package/lib/format.ts +120 -0
- package/lib/infra/.gitkeep +0 -0
- package/lib/infra/deploy.ts +70 -0
- package/lib/infra/migrate.ts +141 -0
- package/lib/newsletter/.gitkeep +0 -0
- package/lib/newsletter/distribute.ts +256 -0
- package/{dist/lib/newsletter/generate-insurance.d.ts → lib/newsletter/generate-insurance.ts} +24 -7
- package/lib/newsletter/generate.ts +735 -0
- package/lib/returnpro/.gitkeep +0 -0
- package/lib/returnpro/anomalies.ts +258 -0
- package/lib/returnpro/audit.ts +194 -0
- package/lib/returnpro/diagnose.ts +400 -0
- package/lib/returnpro/kpis.ts +255 -0
- package/lib/returnpro/templates.ts +323 -0
- package/lib/returnpro/upload-income.ts +311 -0
- package/lib/returnpro/upload-netsuite.ts +696 -0
- package/lib/returnpro/upload-r1.ts +563 -0
- package/lib/returnpro/validate.ts +154 -0
- package/lib/social/meta.ts +228 -0
- package/lib/social/post-generator.ts +468 -0
- package/lib/social/publish.ts +301 -0
- package/lib/social/scraper.ts +503 -0
- package/lib/supabase.ts +25 -0
- package/lib/transactions/delete-batch.ts +258 -0
- package/lib/transactions/ingest.ts +659 -0
- package/lib/transactions/stamp.ts +654 -0
- package/package.json +15 -25
- package/dist/bin/optimal.d.ts +0 -2
- package/dist/bin/optimal.js +0 -995
- package/dist/lib/budget/projections.d.ts +0 -115
- package/dist/lib/budget/projections.js +0 -384
- package/dist/lib/budget/scenarios.d.ts +0 -93
- package/dist/lib/budget/scenarios.js +0 -214
- package/dist/lib/cms/publish-blog.d.ts +0 -62
- package/dist/lib/cms/publish-blog.js +0 -74
- package/dist/lib/cms/strapi-client.d.ts +0 -123
- package/dist/lib/cms/strapi-client.js +0 -213
- package/dist/lib/config.d.ts +0 -55
- package/dist/lib/config.js +0 -206
- package/dist/lib/infra/deploy.d.ts +0 -29
- package/dist/lib/infra/deploy.js +0 -58
- package/dist/lib/infra/migrate.d.ts +0 -34
- package/dist/lib/infra/migrate.js +0 -103
- package/dist/lib/kanban.d.ts +0 -46
- package/dist/lib/kanban.js +0 -118
- package/dist/lib/newsletter/distribute.d.ts +0 -52
- package/dist/lib/newsletter/distribute.js +0 -193
- package/dist/lib/newsletter/generate-insurance.js +0 -36
- package/dist/lib/newsletter/generate.d.ts +0 -104
- package/dist/lib/newsletter/generate.js +0 -571
- package/dist/lib/returnpro/anomalies.d.ts +0 -64
- package/dist/lib/returnpro/anomalies.js +0 -166
- package/dist/lib/returnpro/audit.d.ts +0 -32
- package/dist/lib/returnpro/audit.js +0 -147
- package/dist/lib/returnpro/diagnose.d.ts +0 -52
- package/dist/lib/returnpro/diagnose.js +0 -281
- package/dist/lib/returnpro/kpis.d.ts +0 -32
- package/dist/lib/returnpro/kpis.js +0 -192
- package/dist/lib/returnpro/templates.d.ts +0 -48
- package/dist/lib/returnpro/templates.js +0 -229
- package/dist/lib/returnpro/upload-income.d.ts +0 -25
- package/dist/lib/returnpro/upload-income.js +0 -235
- package/dist/lib/returnpro/upload-netsuite.d.ts +0 -37
- package/dist/lib/returnpro/upload-netsuite.js +0 -566
- package/dist/lib/returnpro/upload-r1.d.ts +0 -48
- package/dist/lib/returnpro/upload-r1.js +0 -398
- package/dist/lib/social/post-generator.d.ts +0 -83
- package/dist/lib/social/post-generator.js +0 -333
- package/dist/lib/social/publish.d.ts +0 -66
- package/dist/lib/social/publish.js +0 -226
- package/dist/lib/social/scraper.d.ts +0 -67
- package/dist/lib/social/scraper.js +0 -361
- package/dist/lib/supabase.d.ts +0 -4
- package/dist/lib/supabase.js +0 -20
- package/dist/lib/transactions/delete-batch.d.ts +0 -60
- package/dist/lib/transactions/delete-batch.js +0 -203
- package/dist/lib/transactions/ingest.d.ts +0 -43
- package/dist/lib/transactions/ingest.js +0 -555
- package/dist/lib/transactions/stamp.d.ts +0 -51
- package/dist/lib/transactions/stamp.js +0 -524
|
@@ -1,566 +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
|
-
// 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
|
-
}
|
|
@@ -1,48 +0,0 @@
|
|
|
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>;
|