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,400 @@
|
|
|
1
|
+
import { getSupabase } from '../supabase.js'
|
|
2
|
+
|
|
3
|
+
// --- Types ---
|
|
4
|
+
|
|
5
|
+
export type DiagnosticIssueKind =
|
|
6
|
+
| 'unresolved_account_code'
|
|
7
|
+
| 'unresolved_program_code'
|
|
8
|
+
| 'unresolved_master_program'
|
|
9
|
+
| 'unresolved_client'
|
|
10
|
+
| 'low_row_count'
|
|
11
|
+
| 'missing_month'
|
|
12
|
+
| 'null_date_rows'
|
|
13
|
+
| 'null_account_code_rows'
|
|
14
|
+
|
|
15
|
+
export interface DiagnosticIssue {
|
|
16
|
+
/** Category of the problem. */
|
|
17
|
+
kind: DiagnosticIssueKind
|
|
18
|
+
/** YYYY-MM if the issue is month-scoped, null if global. */
|
|
19
|
+
month: string | null
|
|
20
|
+
/** Short human-readable summary. */
|
|
21
|
+
message: string
|
|
22
|
+
/** Optional payload with supporting data. */
|
|
23
|
+
detail?: Record<string, unknown>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface DiagnosisResult {
|
|
27
|
+
/** YYYY-MM months that were analysed. */
|
|
28
|
+
monthsAnalysed: string[]
|
|
29
|
+
/** Total rows in stg_financials_raw across the analysed months. */
|
|
30
|
+
totalRows: number
|
|
31
|
+
/** Per-month row counts. */
|
|
32
|
+
rowsPerMonth: Record<string, number>
|
|
33
|
+
/** Median row count across months — used to flag anomalously low months. */
|
|
34
|
+
medianRowCount: number
|
|
35
|
+
/** All issues found. */
|
|
36
|
+
issues: DiagnosticIssue[]
|
|
37
|
+
/** Convenience summary counts. */
|
|
38
|
+
summary: {
|
|
39
|
+
unresolvedAccountCodes: number
|
|
40
|
+
unresolvedProgramCodes: number
|
|
41
|
+
unresolvedMasterPrograms: number
|
|
42
|
+
unresolvedClients: number
|
|
43
|
+
lowRowCountMonths: number
|
|
44
|
+
missingMonths: number
|
|
45
|
+
totalIssues: number
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// --- Internal row shapes ---
|
|
50
|
+
|
|
51
|
+
interface StagingRow {
|
|
52
|
+
raw_id: number
|
|
53
|
+
date: string | null
|
|
54
|
+
account_code: string | null
|
|
55
|
+
account_id: number | null
|
|
56
|
+
program_code: string | null
|
|
57
|
+
program_id_key: number | null
|
|
58
|
+
master_program: string | null
|
|
59
|
+
master_program_id: number | null
|
|
60
|
+
client_id: number | null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface DimAccount {
|
|
64
|
+
account_code: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface DimProgramId {
|
|
68
|
+
program_code: string
|
|
69
|
+
master_program_id: number | null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface DimMasterProgram {
|
|
73
|
+
master_program_id: number
|
|
74
|
+
master_name: string
|
|
75
|
+
client_id: number | null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface DimClient {
|
|
79
|
+
client_id: number
|
|
80
|
+
client_name: string
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// --- Helpers ---
|
|
84
|
+
|
|
85
|
+
const PAGE_SIZE = 1000
|
|
86
|
+
|
|
87
|
+
/** Fetch all rows from a table with pagination, bypassing the 1000-row cap. */
|
|
88
|
+
async function paginateAll<T>(
|
|
89
|
+
table: string,
|
|
90
|
+
select: string,
|
|
91
|
+
orderCol: string,
|
|
92
|
+
): Promise<T[]> {
|
|
93
|
+
const sb = getSupabase('returnpro')
|
|
94
|
+
const all: T[] = []
|
|
95
|
+
let from = 0
|
|
96
|
+
|
|
97
|
+
while (true) {
|
|
98
|
+
const { data, error } = await sb
|
|
99
|
+
.from(table)
|
|
100
|
+
.select(select)
|
|
101
|
+
.order(orderCol)
|
|
102
|
+
.range(from, from + PAGE_SIZE - 1)
|
|
103
|
+
|
|
104
|
+
if (error) throw new Error(`Fetch ${table} failed: ${error.message}`)
|
|
105
|
+
if (!data || data.length === 0) break
|
|
106
|
+
|
|
107
|
+
all.push(...(data as T[]))
|
|
108
|
+
if (data.length < PAGE_SIZE) break
|
|
109
|
+
from += PAGE_SIZE
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return all
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Compute the median of a numeric array. Returns 0 for empty arrays. */
|
|
116
|
+
function median(values: number[]): number {
|
|
117
|
+
if (values.length === 0) return 0
|
|
118
|
+
const sorted = [...values].sort((a, b) => a - b)
|
|
119
|
+
const mid = Math.floor(sorted.length / 2)
|
|
120
|
+
return sorted.length % 2 === 0
|
|
121
|
+
? (sorted[mid - 1] + sorted[mid]) / 2
|
|
122
|
+
: sorted[mid]
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Extract YYYY-MM from a date string. Returns null if unparseable.
|
|
127
|
+
*/
|
|
128
|
+
function toYearMonth(date: string): string | null {
|
|
129
|
+
if (!date) return null
|
|
130
|
+
const d = new Date(date)
|
|
131
|
+
if (isNaN(d.getTime())) return null
|
|
132
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Build the list of expected YYYY-MM months between the earliest and latest
|
|
137
|
+
* months seen in staging data. Used to detect completely missing months.
|
|
138
|
+
*/
|
|
139
|
+
function buildExpectedMonths(present: string[]): string[] {
|
|
140
|
+
if (present.length === 0) return []
|
|
141
|
+
const sorted = [...present].sort()
|
|
142
|
+
const first = sorted[0]
|
|
143
|
+
const last = sorted[sorted.length - 1]
|
|
144
|
+
|
|
145
|
+
const [fy, fm] = first.split('-').map(Number)
|
|
146
|
+
const [ly, lm] = last.split('-').map(Number)
|
|
147
|
+
|
|
148
|
+
const expected: string[] = []
|
|
149
|
+
let y = fy
|
|
150
|
+
let m = fm
|
|
151
|
+
while (y < ly || (y === ly && m <= lm)) {
|
|
152
|
+
expected.push(`${y}-${String(m).padStart(2, '0')}`)
|
|
153
|
+
m++
|
|
154
|
+
if (m > 12) { m = 1; y++ }
|
|
155
|
+
}
|
|
156
|
+
return expected
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// --- Core ---
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Diagnose FK resolution failures and data gaps in stg_financials_raw.
|
|
163
|
+
*
|
|
164
|
+
* Checks performed:
|
|
165
|
+
* 1. Rows with null date or null account_code (data quality)
|
|
166
|
+
* 2. account_codes not present in dim_account
|
|
167
|
+
* 3. program_codes not present in dim_program_id
|
|
168
|
+
* 4. program_codes whose dim_program_id row has a null master_program_id
|
|
169
|
+
* 5. master_program_ids not present in dim_master_program
|
|
170
|
+
* 6. master_programs whose dim_master_program row has a null client_id
|
|
171
|
+
* 7. Months with row counts < 50% of the median (anomalously low)
|
|
172
|
+
* 8. Calendar months completely absent between the first and last month seen
|
|
173
|
+
*
|
|
174
|
+
* @param options.months - If provided, only analyse these YYYY-MM months.
|
|
175
|
+
* If omitted, all months present in staging are analysed.
|
|
176
|
+
*/
|
|
177
|
+
export async function diagnoseMonths(
|
|
178
|
+
options?: { months?: string[] },
|
|
179
|
+
): Promise<DiagnosisResult> {
|
|
180
|
+
const issues: DiagnosticIssue[] = []
|
|
181
|
+
|
|
182
|
+
// --- 1. Load all staging rows (paginated) ---
|
|
183
|
+
const stagingRows = await paginateAll<StagingRow>(
|
|
184
|
+
'stg_financials_raw',
|
|
185
|
+
'raw_id,date,account_code,account_id,program_code,program_id_key,master_program,master_program_id,client_id',
|
|
186
|
+
'raw_id',
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
// --- 2. Load all dimension tables in parallel ---
|
|
190
|
+
const [dimAccounts, dimProgramIds, dimMasterPrograms, dimClients] = await Promise.all([
|
|
191
|
+
paginateAll<DimAccount>('dim_account', 'account_code', 'account_code'),
|
|
192
|
+
paginateAll<DimProgramId>('dim_program_id', 'program_code,master_program_id', 'program_code'),
|
|
193
|
+
paginateAll<DimMasterProgram>('dim_master_program', 'master_program_id,master_name,client_id', 'master_program_id'),
|
|
194
|
+
paginateAll<DimClient>('dim_client', 'client_id,client_name', 'client_id'),
|
|
195
|
+
])
|
|
196
|
+
|
|
197
|
+
// Build lookup sets
|
|
198
|
+
const knownAccountCodes = new Set(dimAccounts.map(r => r.account_code))
|
|
199
|
+
const knownProgramCodes = new Set(dimProgramIds.map(r => r.program_code))
|
|
200
|
+
// dim_program_id entries that have a null master_program_id (orphaned program codes)
|
|
201
|
+
const orphanedProgramCodes = new Set(
|
|
202
|
+
dimProgramIds.filter(r => r.master_program_id === null).map(r => r.program_code),
|
|
203
|
+
)
|
|
204
|
+
const knownMasterProgramIds = new Set(dimMasterPrograms.map(r => r.master_program_id))
|
|
205
|
+
// master programs without a client
|
|
206
|
+
const masterProgramsWithoutClient = new Set(
|
|
207
|
+
dimMasterPrograms.filter(r => r.client_id === null).map(r => r.master_program_id),
|
|
208
|
+
)
|
|
209
|
+
const knownClientIds = new Set(dimClients.map(r => r.client_id))
|
|
210
|
+
|
|
211
|
+
// --- 3. Assign staging rows to months ---
|
|
212
|
+
const rowsByMonth = new Map<string, StagingRow[]>()
|
|
213
|
+
let nullDateCount = 0
|
|
214
|
+
let nullAccountCodeCount = 0
|
|
215
|
+
|
|
216
|
+
for (const row of stagingRows) {
|
|
217
|
+
if (!row.date) {
|
|
218
|
+
nullDateCount++
|
|
219
|
+
continue
|
|
220
|
+
}
|
|
221
|
+
const ym = toYearMonth(row.date)
|
|
222
|
+
if (!ym) {
|
|
223
|
+
nullDateCount++
|
|
224
|
+
continue
|
|
225
|
+
}
|
|
226
|
+
const existing = rowsByMonth.get(ym) ?? []
|
|
227
|
+
existing.push(row)
|
|
228
|
+
rowsByMonth.set(ym, existing)
|
|
229
|
+
|
|
230
|
+
if (!row.account_code) nullAccountCodeCount++
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// --- 4. Apply month filter ---
|
|
234
|
+
let targetMonths: string[]
|
|
235
|
+
if (options?.months && options.months.length > 0) {
|
|
236
|
+
targetMonths = options.months.filter(m => rowsByMonth.has(m)).sort()
|
|
237
|
+
} else {
|
|
238
|
+
targetMonths = [...rowsByMonth.keys()].sort()
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// --- 5. Global data quality issues ---
|
|
242
|
+
if (nullDateCount > 0) {
|
|
243
|
+
issues.push({
|
|
244
|
+
kind: 'null_date_rows',
|
|
245
|
+
month: null,
|
|
246
|
+
message: `${nullDateCount} row(s) in stg_financials_raw have a null or unparseable date`,
|
|
247
|
+
detail: { count: nullDateCount },
|
|
248
|
+
})
|
|
249
|
+
}
|
|
250
|
+
if (nullAccountCodeCount > 0) {
|
|
251
|
+
issues.push({
|
|
252
|
+
kind: 'null_account_code_rows',
|
|
253
|
+
month: null,
|
|
254
|
+
message: `${nullAccountCodeCount} row(s) in stg_financials_raw have a null account_code`,
|
|
255
|
+
detail: { count: nullAccountCodeCount },
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// --- 6. Per-month analysis ---
|
|
260
|
+
const rowsPerMonth: Record<string, number> = {}
|
|
261
|
+
|
|
262
|
+
// Aggregate FK failure sets per dimension (global, deduplicated)
|
|
263
|
+
const unresolvedAccountCodes = new Set<string>()
|
|
264
|
+
const unresolvedProgramCodes = new Set<string>()
|
|
265
|
+
const unresolvedMasterProgramIds = new Set<number>()
|
|
266
|
+
const unresolvedClientIds = new Set<number>()
|
|
267
|
+
|
|
268
|
+
for (const month of targetMonths) {
|
|
269
|
+
const rows = rowsByMonth.get(month) ?? []
|
|
270
|
+
rowsPerMonth[month] = rows.length
|
|
271
|
+
|
|
272
|
+
for (const row of rows) {
|
|
273
|
+
// account_code → dim_account
|
|
274
|
+
if (row.account_code && !knownAccountCodes.has(row.account_code)) {
|
|
275
|
+
unresolvedAccountCodes.add(row.account_code)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// program_code → dim_program_id
|
|
279
|
+
if (row.program_code) {
|
|
280
|
+
if (!knownProgramCodes.has(row.program_code)) {
|
|
281
|
+
unresolvedProgramCodes.add(row.program_code)
|
|
282
|
+
} else if (orphanedProgramCodes.has(row.program_code)) {
|
|
283
|
+
// The program_code exists in dim_program_id but its master_program_id is null
|
|
284
|
+
unresolvedProgramCodes.add(row.program_code)
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// master_program_id → dim_master_program
|
|
289
|
+
if (row.master_program_id !== null && row.master_program_id !== undefined) {
|
|
290
|
+
if (!knownMasterProgramIds.has(row.master_program_id)) {
|
|
291
|
+
unresolvedMasterProgramIds.add(row.master_program_id)
|
|
292
|
+
} else if (masterProgramsWithoutClient.has(row.master_program_id)) {
|
|
293
|
+
// master_program exists but has no client_id
|
|
294
|
+
unresolvedClientIds.add(row.master_program_id)
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// client_id → dim_client (direct FK on staging row)
|
|
299
|
+
if (row.client_id !== null && row.client_id !== undefined) {
|
|
300
|
+
if (!knownClientIds.has(row.client_id)) {
|
|
301
|
+
unresolvedClientIds.add(row.client_id)
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Emit per-dimension issues (global, not per-month — less noise)
|
|
308
|
+
if (unresolvedAccountCodes.size > 0) {
|
|
309
|
+
const codes = [...unresolvedAccountCodes].sort()
|
|
310
|
+
issues.push({
|
|
311
|
+
kind: 'unresolved_account_code',
|
|
312
|
+
month: null,
|
|
313
|
+
message: `${codes.length} account_code(s) in staging do not resolve to dim_account`,
|
|
314
|
+
detail: { codes },
|
|
315
|
+
})
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (unresolvedProgramCodes.size > 0) {
|
|
319
|
+
const codes = [...unresolvedProgramCodes].sort()
|
|
320
|
+
issues.push({
|
|
321
|
+
kind: 'unresolved_program_code',
|
|
322
|
+
month: null,
|
|
323
|
+
message: `${codes.length} program_code(s) in staging do not resolve (missing from dim_program_id or dim_program_id.master_program_id is null)`,
|
|
324
|
+
detail: { codes },
|
|
325
|
+
})
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (unresolvedMasterProgramIds.size > 0) {
|
|
329
|
+
const ids = [...unresolvedMasterProgramIds].sort((a, b) => a - b)
|
|
330
|
+
issues.push({
|
|
331
|
+
kind: 'unresolved_master_program',
|
|
332
|
+
month: null,
|
|
333
|
+
message: `${ids.length} master_program_id(s) in staging do not resolve to dim_master_program`,
|
|
334
|
+
detail: { ids },
|
|
335
|
+
})
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (unresolvedClientIds.size > 0) {
|
|
339
|
+
const ids = [...unresolvedClientIds].sort((a, b) => a - b)
|
|
340
|
+
issues.push({
|
|
341
|
+
kind: 'unresolved_client',
|
|
342
|
+
month: null,
|
|
343
|
+
message: `${ids.length} client_id(s) in staging do not resolve to dim_client (or master_program has no client)`,
|
|
344
|
+
detail: { ids },
|
|
345
|
+
})
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// --- 7. Row count anomalies (< 50% of median) ---
|
|
349
|
+
const counts = targetMonths.map(m => rowsPerMonth[m] ?? 0)
|
|
350
|
+
const med = median(counts)
|
|
351
|
+
const lowThreshold = med * 0.5
|
|
352
|
+
|
|
353
|
+
for (const month of targetMonths) {
|
|
354
|
+
const count = rowsPerMonth[month] ?? 0
|
|
355
|
+
if (med > 0 && count < lowThreshold) {
|
|
356
|
+
issues.push({
|
|
357
|
+
kind: 'low_row_count',
|
|
358
|
+
month,
|
|
359
|
+
message: `${month} has only ${count} rows — below 50% of median (${med})`,
|
|
360
|
+
detail: { rowCount: count, median: med, threshold: lowThreshold },
|
|
361
|
+
})
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// --- 8. Missing months (gaps in the calendar range) ---
|
|
366
|
+
const allPresentMonths = [...rowsByMonth.keys()].sort()
|
|
367
|
+
const expectedMonths = buildExpectedMonths(allPresentMonths)
|
|
368
|
+
const presentSet = new Set(allPresentMonths)
|
|
369
|
+
|
|
370
|
+
for (const expected of expectedMonths) {
|
|
371
|
+
// Only flag months within the analysed range that are entirely absent
|
|
372
|
+
if (!presentSet.has(expected)) {
|
|
373
|
+
issues.push({
|
|
374
|
+
kind: 'missing_month',
|
|
375
|
+
month: expected,
|
|
376
|
+
message: `${expected} has no rows in stg_financials_raw — month is missing`,
|
|
377
|
+
})
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// --- 9. Compute summary ---
|
|
382
|
+
const summary = {
|
|
383
|
+
unresolvedAccountCodes: unresolvedAccountCodes.size,
|
|
384
|
+
unresolvedProgramCodes: unresolvedProgramCodes.size,
|
|
385
|
+
unresolvedMasterPrograms: unresolvedMasterProgramIds.size,
|
|
386
|
+
unresolvedClients: unresolvedClientIds.size,
|
|
387
|
+
lowRowCountMonths: issues.filter(i => i.kind === 'low_row_count').length,
|
|
388
|
+
missingMonths: issues.filter(i => i.kind === 'missing_month').length,
|
|
389
|
+
totalIssues: issues.length,
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return {
|
|
393
|
+
monthsAnalysed: targetMonths,
|
|
394
|
+
totalRows: counts.reduce((a, b) => a + b, 0),
|
|
395
|
+
rowsPerMonth,
|
|
396
|
+
medianRowCount: med,
|
|
397
|
+
issues,
|
|
398
|
+
summary,
|
|
399
|
+
}
|
|
400
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { getSupabase } from '../supabase.js'
|
|
2
|
+
|
|
3
|
+
// --- Types ---
|
|
4
|
+
|
|
5
|
+
export interface KpiRow {
|
|
6
|
+
month: string
|
|
7
|
+
kpiName: string
|
|
8
|
+
kpiBucket: string
|
|
9
|
+
programName: string
|
|
10
|
+
clientName: string
|
|
11
|
+
totalAmount: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface RpcResult {
|
|
15
|
+
kpi_id: number
|
|
16
|
+
kpi_name: string
|
|
17
|
+
kpi_bucket: string
|
|
18
|
+
master_program_id: number
|
|
19
|
+
master_name: string
|
|
20
|
+
client_id: number
|
|
21
|
+
client_name: string
|
|
22
|
+
month: string
|
|
23
|
+
total_amount: string | number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// --- Helpers ---
|
|
27
|
+
|
|
28
|
+
const PAGE_SIZE = 1000
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Fetch distinct months available in stg_financials_raw.
|
|
32
|
+
* Returns sorted YYYY-MM strings.
|
|
33
|
+
*/
|
|
34
|
+
async function fetchAvailableMonths(): Promise<string[]> {
|
|
35
|
+
const sb = getSupabase('returnpro')
|
|
36
|
+
const months = new Set<string>()
|
|
37
|
+
let from = 0
|
|
38
|
+
|
|
39
|
+
while (true) {
|
|
40
|
+
const { data, error } = await sb
|
|
41
|
+
.from('stg_financials_raw')
|
|
42
|
+
.select('date')
|
|
43
|
+
.order('date')
|
|
44
|
+
.range(from, from + PAGE_SIZE - 1)
|
|
45
|
+
|
|
46
|
+
if (error) throw new Error(`Fetch months failed: ${error.message}`)
|
|
47
|
+
if (!data || data.length === 0) break
|
|
48
|
+
|
|
49
|
+
for (const row of data as Array<{ date: string }>) {
|
|
50
|
+
if (row.date) months.add(row.date.substring(0, 7))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (data.length < PAGE_SIZE) break
|
|
54
|
+
from += PAGE_SIZE
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return [...months].sort()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Call the get_kpi_totals_by_program_client RPC function for a single month.
|
|
62
|
+
* Optionally filter by master_program_id.
|
|
63
|
+
*/
|
|
64
|
+
async function fetchKpisByMonth(
|
|
65
|
+
month: string,
|
|
66
|
+
masterProgramId?: number,
|
|
67
|
+
): Promise<RpcResult[]> {
|
|
68
|
+
const sb = getSupabase('returnpro')
|
|
69
|
+
const params: Record<string, unknown> = { p_month: month }
|
|
70
|
+
if (masterProgramId !== undefined) {
|
|
71
|
+
params.p_master_program_id = masterProgramId
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const { data, error } = await sb.rpc('get_kpi_totals_by_program_client', params)
|
|
75
|
+
|
|
76
|
+
if (error) throw new Error(`RPC get_kpi_totals_by_program_client failed for ${month}: ${error.message}`)
|
|
77
|
+
return (data ?? []) as RpcResult[]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Resolve program name filter to master_program_id(s).
|
|
82
|
+
* Searches both dim_master_program.master_name (full names like "Bass Pro Shops Liquidation (Finished)")
|
|
83
|
+
* and dim_program_id.program_name (codes like "BRTON-WM-LIQ") for case-insensitive partial match.
|
|
84
|
+
*/
|
|
85
|
+
async function resolveProgramIds(names: string[]): Promise<number[]> {
|
|
86
|
+
const sb = getSupabase('returnpro')
|
|
87
|
+
|
|
88
|
+
// Fetch both tables in parallel
|
|
89
|
+
const [masterRes, programRes] = await Promise.all([
|
|
90
|
+
sb.from('dim_master_program').select('master_program_id,master_name').order('master_name'),
|
|
91
|
+
sb.from('dim_program_id').select('program_id_key,program_code,master_program_id').order('program_code'),
|
|
92
|
+
])
|
|
93
|
+
|
|
94
|
+
if (masterRes.error) throw new Error(`Fetch dim_master_program failed: ${masterRes.error.message}`)
|
|
95
|
+
if (programRes.error) throw new Error(`Fetch dim_program_id failed: ${programRes.error.message}`)
|
|
96
|
+
|
|
97
|
+
const lowerNames = names.map(n => n.toLowerCase())
|
|
98
|
+
const ids = new Set<number>()
|
|
99
|
+
|
|
100
|
+
// Match against master program names
|
|
101
|
+
for (const row of (masterRes.data ?? []) as Array<{ master_program_id: number; master_name: string }>) {
|
|
102
|
+
if (lowerNames.some(n => row.master_name.toLowerCase().includes(n))) {
|
|
103
|
+
ids.add(row.master_program_id)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Match against program codes and map back to master_program_id
|
|
108
|
+
for (const row of (programRes.data ?? []) as Array<{ program_id_key: number; program_code: string; master_program_id: number | null }>) {
|
|
109
|
+
if (row.master_program_id && lowerNames.some(n => row.program_code.toLowerCase().includes(n))) {
|
|
110
|
+
ids.add(row.master_program_id)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return [...ids]
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// --- Core ---
|
|
118
|
+
|
|
119
|
+
export interface ExportKpiOptions {
|
|
120
|
+
/** YYYY-MM months to export. If omitted, uses the 3 most recent months. */
|
|
121
|
+
months?: string[]
|
|
122
|
+
/** Program name substrings to filter by. Case-insensitive partial match. */
|
|
123
|
+
programs?: string[]
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Export KPI data from ReturnPro, aggregated by program/client/month.
|
|
128
|
+
*
|
|
129
|
+
* Calls the `get_kpi_totals_by_program_client` Supabase RPC function
|
|
130
|
+
* (same as dashboard-returnpro's /api/kpis/by-program-client route).
|
|
131
|
+
*
|
|
132
|
+
* @returns Flat array of KpiRow sorted by month, kpiName, clientName, programName.
|
|
133
|
+
*/
|
|
134
|
+
export async function exportKpis(options?: ExportKpiOptions): Promise<KpiRow[]> {
|
|
135
|
+
// Resolve months
|
|
136
|
+
let targetMonths: string[]
|
|
137
|
+
if (options?.months && options.months.length > 0) {
|
|
138
|
+
targetMonths = options.months.sort()
|
|
139
|
+
} else {
|
|
140
|
+
const allMonths = await fetchAvailableMonths()
|
|
141
|
+
// Default to 3 most recent months
|
|
142
|
+
targetMonths = allMonths.slice(-3)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (targetMonths.length === 0) {
|
|
146
|
+
return []
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Resolve program filter
|
|
150
|
+
let programIds: number[] | undefined
|
|
151
|
+
if (options?.programs && options.programs.length > 0) {
|
|
152
|
+
programIds = await resolveProgramIds(options.programs)
|
|
153
|
+
if (programIds.length === 0) {
|
|
154
|
+
console.error(`No programs matched: ${options.programs.join(', ')}`)
|
|
155
|
+
return []
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Fetch KPI data for each month
|
|
160
|
+
// If we have program filter, call once per programId x month
|
|
161
|
+
// If no program filter, call once per month (no filter)
|
|
162
|
+
const allRows: KpiRow[] = []
|
|
163
|
+
|
|
164
|
+
for (const month of targetMonths) {
|
|
165
|
+
if (programIds && programIds.length > 0) {
|
|
166
|
+
// Call per program to use the RPC filter
|
|
167
|
+
for (const pid of programIds) {
|
|
168
|
+
const results = await fetchKpisByMonth(month, pid)
|
|
169
|
+
for (const row of results) {
|
|
170
|
+
allRows.push(mapRow(row))
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
} else {
|
|
174
|
+
const results = await fetchKpisByMonth(month)
|
|
175
|
+
for (const row of results) {
|
|
176
|
+
allRows.push(mapRow(row))
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Sort: month, kpiName, clientName, programName
|
|
182
|
+
allRows.sort((a, b) =>
|
|
183
|
+
a.month.localeCompare(b.month)
|
|
184
|
+
|| a.kpiName.localeCompare(b.kpiName)
|
|
185
|
+
|| a.clientName.localeCompare(b.clientName)
|
|
186
|
+
|| a.programName.localeCompare(b.programName)
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
return allRows
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function mapRow(row: RpcResult): KpiRow {
|
|
193
|
+
return {
|
|
194
|
+
month: row.month,
|
|
195
|
+
kpiName: row.kpi_name,
|
|
196
|
+
kpiBucket: row.kpi_bucket,
|
|
197
|
+
programName: row.master_name ?? '- None -',
|
|
198
|
+
clientName: row.client_name ?? 'Unknown',
|
|
199
|
+
totalAmount: typeof row.total_amount === 'string'
|
|
200
|
+
? parseFloat(row.total_amount) || 0
|
|
201
|
+
: Number(row.total_amount) || 0,
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// --- Formatting ---
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Format KPI rows as a compact markdown table.
|
|
209
|
+
* Amounts shown in compact notation ($1.2M, $890K).
|
|
210
|
+
*/
|
|
211
|
+
export function formatKpiTable(rows: KpiRow[]): string {
|
|
212
|
+
if (rows.length === 0) return 'No KPI data found.'
|
|
213
|
+
|
|
214
|
+
const lines: string[] = []
|
|
215
|
+
lines.push('| Month | KPI | Bucket | Client | Program | Amount |')
|
|
216
|
+
lines.push('|---------|-----|--------|--------|---------|--------|')
|
|
217
|
+
|
|
218
|
+
for (const r of rows) {
|
|
219
|
+
lines.push(
|
|
220
|
+
`| ${r.month} | ${r.kpiName} | ${r.kpiBucket} | ${r.clientName} | ${r.programName} | ${fmtAmount(r.totalAmount)} |`
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
lines.push(`\n${rows.length} rows`)
|
|
225
|
+
return lines.join('\n')
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Format KPI rows as CSV.
|
|
230
|
+
*/
|
|
231
|
+
export function formatKpiCsv(rows: KpiRow[]): string {
|
|
232
|
+
const lines: string[] = []
|
|
233
|
+
lines.push('month,kpi_name,kpi_bucket,client_name,program_name,total_amount')
|
|
234
|
+
for (const r of rows) {
|
|
235
|
+
lines.push(
|
|
236
|
+
`${r.month},${csvEscape(r.kpiName)},${csvEscape(r.kpiBucket)},${csvEscape(r.clientName)},${csvEscape(r.programName)},${r.totalAmount.toFixed(2)}`
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
return lines.join('\n')
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function csvEscape(s: string): string {
|
|
243
|
+
if (s.includes(',') || s.includes('"') || s.includes('\n')) {
|
|
244
|
+
return `"${s.replace(/"/g, '""')}"`
|
|
245
|
+
}
|
|
246
|
+
return s
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function fmtAmount(n: number): string {
|
|
250
|
+
const abs = Math.abs(n)
|
|
251
|
+
const sign = n < 0 ? '-' : ''
|
|
252
|
+
if (abs >= 1_000_000) return `${sign}$${(abs / 1_000_000).toFixed(1)}M`
|
|
253
|
+
if (abs >= 1_000) return `${sign}$${(abs / 1_000).toFixed(1)}K`
|
|
254
|
+
return `${sign}$${abs.toFixed(0)}`
|
|
255
|
+
}
|