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