optimal-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +175 -0
- package/dist/bin/optimal.d.ts +2 -0
- package/dist/bin/optimal.js +995 -0
- package/dist/lib/budget/projections.d.ts +115 -0
- package/dist/lib/budget/projections.js +384 -0
- package/dist/lib/budget/scenarios.d.ts +93 -0
- package/dist/lib/budget/scenarios.js +214 -0
- package/dist/lib/cms/publish-blog.d.ts +62 -0
- package/dist/lib/cms/publish-blog.js +74 -0
- package/dist/lib/cms/strapi-client.d.ts +123 -0
- package/dist/lib/cms/strapi-client.js +213 -0
- package/dist/lib/config.d.ts +55 -0
- package/dist/lib/config.js +206 -0
- package/dist/lib/infra/deploy.d.ts +29 -0
- package/dist/lib/infra/deploy.js +58 -0
- package/dist/lib/infra/migrate.d.ts +34 -0
- package/dist/lib/infra/migrate.js +103 -0
- package/dist/lib/kanban.d.ts +46 -0
- package/dist/lib/kanban.js +118 -0
- package/dist/lib/newsletter/distribute.d.ts +52 -0
- package/dist/lib/newsletter/distribute.js +193 -0
- package/dist/lib/newsletter/generate-insurance.d.ts +42 -0
- package/dist/lib/newsletter/generate-insurance.js +36 -0
- package/dist/lib/newsletter/generate.d.ts +104 -0
- package/dist/lib/newsletter/generate.js +571 -0
- package/dist/lib/returnpro/anomalies.d.ts +64 -0
- package/dist/lib/returnpro/anomalies.js +166 -0
- package/dist/lib/returnpro/audit.d.ts +32 -0
- package/dist/lib/returnpro/audit.js +147 -0
- package/dist/lib/returnpro/diagnose.d.ts +52 -0
- package/dist/lib/returnpro/diagnose.js +281 -0
- package/dist/lib/returnpro/kpis.d.ts +32 -0
- package/dist/lib/returnpro/kpis.js +192 -0
- package/dist/lib/returnpro/templates.d.ts +48 -0
- package/dist/lib/returnpro/templates.js +229 -0
- package/dist/lib/returnpro/upload-income.d.ts +25 -0
- package/dist/lib/returnpro/upload-income.js +235 -0
- package/dist/lib/returnpro/upload-netsuite.d.ts +37 -0
- package/dist/lib/returnpro/upload-netsuite.js +566 -0
- package/dist/lib/returnpro/upload-r1.d.ts +48 -0
- package/dist/lib/returnpro/upload-r1.js +398 -0
- package/dist/lib/social/post-generator.d.ts +83 -0
- package/dist/lib/social/post-generator.js +333 -0
- package/dist/lib/social/publish.d.ts +66 -0
- package/dist/lib/social/publish.js +226 -0
- package/dist/lib/social/scraper.d.ts +67 -0
- package/dist/lib/social/scraper.js +361 -0
- package/dist/lib/supabase.d.ts +4 -0
- package/dist/lib/supabase.js +20 -0
- package/dist/lib/transactions/delete-batch.d.ts +60 -0
- package/dist/lib/transactions/delete-batch.js +203 -0
- package/dist/lib/transactions/ingest.d.ts +43 -0
- package/dist/lib/transactions/ingest.js +555 -0
- package/dist/lib/transactions/stamp.d.ts +51 -0
- package/dist/lib/transactions/stamp.js +524 -0
- package/package.json +50 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export interface KpiRow {
|
|
2
|
+
month: string;
|
|
3
|
+
kpiName: string;
|
|
4
|
+
kpiBucket: string;
|
|
5
|
+
programName: string;
|
|
6
|
+
clientName: string;
|
|
7
|
+
totalAmount: number;
|
|
8
|
+
}
|
|
9
|
+
export interface ExportKpiOptions {
|
|
10
|
+
/** YYYY-MM months to export. If omitted, uses the 3 most recent months. */
|
|
11
|
+
months?: string[];
|
|
12
|
+
/** Program name substrings to filter by. Case-insensitive partial match. */
|
|
13
|
+
programs?: string[];
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Export KPI data from ReturnPro, aggregated by program/client/month.
|
|
17
|
+
*
|
|
18
|
+
* Calls the `get_kpi_totals_by_program_client` Supabase RPC function
|
|
19
|
+
* (same as dashboard-returnpro's /api/kpis/by-program-client route).
|
|
20
|
+
*
|
|
21
|
+
* @returns Flat array of KpiRow sorted by month, kpiName, clientName, programName.
|
|
22
|
+
*/
|
|
23
|
+
export declare function exportKpis(options?: ExportKpiOptions): Promise<KpiRow[]>;
|
|
24
|
+
/**
|
|
25
|
+
* Format KPI rows as a compact markdown table.
|
|
26
|
+
* Amounts shown in compact notation ($1.2M, $890K).
|
|
27
|
+
*/
|
|
28
|
+
export declare function formatKpiTable(rows: KpiRow[]): string;
|
|
29
|
+
/**
|
|
30
|
+
* Format KPI rows as CSV.
|
|
31
|
+
*/
|
|
32
|
+
export declare function formatKpiCsv(rows: KpiRow[]): string;
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { getSupabase } from '../supabase.js';
|
|
2
|
+
// --- Helpers ---
|
|
3
|
+
const PAGE_SIZE = 1000;
|
|
4
|
+
/**
|
|
5
|
+
* Fetch distinct months available in stg_financials_raw.
|
|
6
|
+
* Returns sorted YYYY-MM strings.
|
|
7
|
+
*/
|
|
8
|
+
async function fetchAvailableMonths() {
|
|
9
|
+
const sb = getSupabase('returnpro');
|
|
10
|
+
const months = new Set();
|
|
11
|
+
let from = 0;
|
|
12
|
+
while (true) {
|
|
13
|
+
const { data, error } = await sb
|
|
14
|
+
.from('stg_financials_raw')
|
|
15
|
+
.select('date')
|
|
16
|
+
.order('date')
|
|
17
|
+
.range(from, from + PAGE_SIZE - 1);
|
|
18
|
+
if (error)
|
|
19
|
+
throw new Error(`Fetch months failed: ${error.message}`);
|
|
20
|
+
if (!data || data.length === 0)
|
|
21
|
+
break;
|
|
22
|
+
for (const row of data) {
|
|
23
|
+
if (row.date)
|
|
24
|
+
months.add(row.date.substring(0, 7));
|
|
25
|
+
}
|
|
26
|
+
if (data.length < PAGE_SIZE)
|
|
27
|
+
break;
|
|
28
|
+
from += PAGE_SIZE;
|
|
29
|
+
}
|
|
30
|
+
return [...months].sort();
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Call the get_kpi_totals_by_program_client RPC function for a single month.
|
|
34
|
+
* Optionally filter by master_program_id.
|
|
35
|
+
*/
|
|
36
|
+
async function fetchKpisByMonth(month, masterProgramId) {
|
|
37
|
+
const sb = getSupabase('returnpro');
|
|
38
|
+
const params = { p_month: month };
|
|
39
|
+
if (masterProgramId !== undefined) {
|
|
40
|
+
params.p_master_program_id = masterProgramId;
|
|
41
|
+
}
|
|
42
|
+
const { data, error } = await sb.rpc('get_kpi_totals_by_program_client', params);
|
|
43
|
+
if (error)
|
|
44
|
+
throw new Error(`RPC get_kpi_totals_by_program_client failed for ${month}: ${error.message}`);
|
|
45
|
+
return (data ?? []);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Resolve program name filter to master_program_id(s).
|
|
49
|
+
* Searches both dim_master_program.master_name (full names like "Bass Pro Shops Liquidation (Finished)")
|
|
50
|
+
* and dim_program_id.program_name (codes like "BRTON-WM-LIQ") for case-insensitive partial match.
|
|
51
|
+
*/
|
|
52
|
+
async function resolveProgramIds(names) {
|
|
53
|
+
const sb = getSupabase('returnpro');
|
|
54
|
+
// Fetch both tables in parallel
|
|
55
|
+
const [masterRes, programRes] = await Promise.all([
|
|
56
|
+
sb.from('dim_master_program').select('master_program_id,master_name').order('master_name'),
|
|
57
|
+
sb.from('dim_program_id').select('program_id_key,program_code,master_program_id').order('program_code'),
|
|
58
|
+
]);
|
|
59
|
+
if (masterRes.error)
|
|
60
|
+
throw new Error(`Fetch dim_master_program failed: ${masterRes.error.message}`);
|
|
61
|
+
if (programRes.error)
|
|
62
|
+
throw new Error(`Fetch dim_program_id failed: ${programRes.error.message}`);
|
|
63
|
+
const lowerNames = names.map(n => n.toLowerCase());
|
|
64
|
+
const ids = new Set();
|
|
65
|
+
// Match against master program names
|
|
66
|
+
for (const row of (masterRes.data ?? [])) {
|
|
67
|
+
if (lowerNames.some(n => row.master_name.toLowerCase().includes(n))) {
|
|
68
|
+
ids.add(row.master_program_id);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// Match against program codes and map back to master_program_id
|
|
72
|
+
for (const row of (programRes.data ?? [])) {
|
|
73
|
+
if (row.master_program_id && lowerNames.some(n => row.program_code.toLowerCase().includes(n))) {
|
|
74
|
+
ids.add(row.master_program_id);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return [...ids];
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Export KPI data from ReturnPro, aggregated by program/client/month.
|
|
81
|
+
*
|
|
82
|
+
* Calls the `get_kpi_totals_by_program_client` Supabase RPC function
|
|
83
|
+
* (same as dashboard-returnpro's /api/kpis/by-program-client route).
|
|
84
|
+
*
|
|
85
|
+
* @returns Flat array of KpiRow sorted by month, kpiName, clientName, programName.
|
|
86
|
+
*/
|
|
87
|
+
export async function exportKpis(options) {
|
|
88
|
+
// Resolve months
|
|
89
|
+
let targetMonths;
|
|
90
|
+
if (options?.months && options.months.length > 0) {
|
|
91
|
+
targetMonths = options.months.sort();
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
const allMonths = await fetchAvailableMonths();
|
|
95
|
+
// Default to 3 most recent months
|
|
96
|
+
targetMonths = allMonths.slice(-3);
|
|
97
|
+
}
|
|
98
|
+
if (targetMonths.length === 0) {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
// Resolve program filter
|
|
102
|
+
let programIds;
|
|
103
|
+
if (options?.programs && options.programs.length > 0) {
|
|
104
|
+
programIds = await resolveProgramIds(options.programs);
|
|
105
|
+
if (programIds.length === 0) {
|
|
106
|
+
console.error(`No programs matched: ${options.programs.join(', ')}`);
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// Fetch KPI data for each month
|
|
111
|
+
// If we have program filter, call once per programId x month
|
|
112
|
+
// If no program filter, call once per month (no filter)
|
|
113
|
+
const allRows = [];
|
|
114
|
+
for (const month of targetMonths) {
|
|
115
|
+
if (programIds && programIds.length > 0) {
|
|
116
|
+
// Call per program to use the RPC filter
|
|
117
|
+
for (const pid of programIds) {
|
|
118
|
+
const results = await fetchKpisByMonth(month, pid);
|
|
119
|
+
for (const row of results) {
|
|
120
|
+
allRows.push(mapRow(row));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
const results = await fetchKpisByMonth(month);
|
|
126
|
+
for (const row of results) {
|
|
127
|
+
allRows.push(mapRow(row));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
// Sort: month, kpiName, clientName, programName
|
|
132
|
+
allRows.sort((a, b) => a.month.localeCompare(b.month)
|
|
133
|
+
|| a.kpiName.localeCompare(b.kpiName)
|
|
134
|
+
|| a.clientName.localeCompare(b.clientName)
|
|
135
|
+
|| a.programName.localeCompare(b.programName));
|
|
136
|
+
return allRows;
|
|
137
|
+
}
|
|
138
|
+
function mapRow(row) {
|
|
139
|
+
return {
|
|
140
|
+
month: row.month,
|
|
141
|
+
kpiName: row.kpi_name,
|
|
142
|
+
kpiBucket: row.kpi_bucket,
|
|
143
|
+
programName: row.master_name ?? '- None -',
|
|
144
|
+
clientName: row.client_name ?? 'Unknown',
|
|
145
|
+
totalAmount: typeof row.total_amount === 'string'
|
|
146
|
+
? parseFloat(row.total_amount) || 0
|
|
147
|
+
: Number(row.total_amount) || 0,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
// --- Formatting ---
|
|
151
|
+
/**
|
|
152
|
+
* Format KPI rows as a compact markdown table.
|
|
153
|
+
* Amounts shown in compact notation ($1.2M, $890K).
|
|
154
|
+
*/
|
|
155
|
+
export function formatKpiTable(rows) {
|
|
156
|
+
if (rows.length === 0)
|
|
157
|
+
return 'No KPI data found.';
|
|
158
|
+
const lines = [];
|
|
159
|
+
lines.push('| Month | KPI | Bucket | Client | Program | Amount |');
|
|
160
|
+
lines.push('|---------|-----|--------|--------|---------|--------|');
|
|
161
|
+
for (const r of rows) {
|
|
162
|
+
lines.push(`| ${r.month} | ${r.kpiName} | ${r.kpiBucket} | ${r.clientName} | ${r.programName} | ${fmtAmount(r.totalAmount)} |`);
|
|
163
|
+
}
|
|
164
|
+
lines.push(`\n${rows.length} rows`);
|
|
165
|
+
return lines.join('\n');
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Format KPI rows as CSV.
|
|
169
|
+
*/
|
|
170
|
+
export function formatKpiCsv(rows) {
|
|
171
|
+
const lines = [];
|
|
172
|
+
lines.push('month,kpi_name,kpi_bucket,client_name,program_name,total_amount');
|
|
173
|
+
for (const r of rows) {
|
|
174
|
+
lines.push(`${r.month},${csvEscape(r.kpiName)},${csvEscape(r.kpiBucket)},${csvEscape(r.clientName)},${csvEscape(r.programName)},${r.totalAmount.toFixed(2)}`);
|
|
175
|
+
}
|
|
176
|
+
return lines.join('\n');
|
|
177
|
+
}
|
|
178
|
+
function csvEscape(s) {
|
|
179
|
+
if (s.includes(',') || s.includes('"') || s.includes('\n')) {
|
|
180
|
+
return `"${s.replace(/"/g, '""')}"`;
|
|
181
|
+
}
|
|
182
|
+
return s;
|
|
183
|
+
}
|
|
184
|
+
function fmtAmount(n) {
|
|
185
|
+
const abs = Math.abs(n);
|
|
186
|
+
const sign = n < 0 ? '-' : '';
|
|
187
|
+
if (abs >= 1_000_000)
|
|
188
|
+
return `${sign}$${(abs / 1_000_000).toFixed(1)}M`;
|
|
189
|
+
if (abs >= 1_000)
|
|
190
|
+
return `${sign}$${(abs / 1_000).toFixed(1)}K`;
|
|
191
|
+
return `${sign}$${abs.toFixed(0)}`;
|
|
192
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export interface TemplateResult {
|
|
2
|
+
/** Absolute path where the XLSX was written */
|
|
3
|
+
outputPath: string;
|
|
4
|
+
/** Number of account columns included */
|
|
5
|
+
accountCount: number;
|
|
6
|
+
/** Number of program rows included */
|
|
7
|
+
programCount: number;
|
|
8
|
+
/** Month string used (e.g. "Jan 2026") or undefined if no month was specified */
|
|
9
|
+
month: string | undefined;
|
|
10
|
+
}
|
|
11
|
+
export interface GenerateNetSuiteTemplateOptions {
|
|
12
|
+
/**
|
|
13
|
+
* Fiscal year string, e.g. "2025" or "FY2026". Currently unused for filtering
|
|
14
|
+
* (all active programs/accounts are included regardless), but stored in the
|
|
15
|
+
* Instructions sheet for reference.
|
|
16
|
+
*/
|
|
17
|
+
fiscalYear?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Optional month string in "MMM YYYY" format (e.g. "Jan 2026").
|
|
20
|
+
* When provided the template's Upload Date and Solution7 Date columns are
|
|
21
|
+
* pre-filled. When omitted those cells are left empty.
|
|
22
|
+
*/
|
|
23
|
+
month?: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Generate a blank NetSuite upload template XLSX.
|
|
27
|
+
*
|
|
28
|
+
* The workbook mirrors the structure produced by dashboard-returnpro's
|
|
29
|
+
* `/api/admin/netsuite-template` route:
|
|
30
|
+
*
|
|
31
|
+
* Sheet 1 — Data Entry
|
|
32
|
+
* Row 1: headers — Master Program | Program ID | Date (Upload) | Date (Solution7) | <account_code>…
|
|
33
|
+
* Row 2+: one row per active program, upload/solution7 date cells pre-filled when `month` is given
|
|
34
|
+
*
|
|
35
|
+
* Sheet 2 — Account Reference
|
|
36
|
+
* Account Code | Account ID | NetSuite Label
|
|
37
|
+
*
|
|
38
|
+
* Sheet 3 — Instructions
|
|
39
|
+
* Key/value metadata + usage instructions
|
|
40
|
+
*
|
|
41
|
+
* Accounts are ordered by account_id; programs are ordered by
|
|
42
|
+
* master_program_name then program_code (matching the route).
|
|
43
|
+
*
|
|
44
|
+
* @param outputPath Destination file path (will be created or overwritten).
|
|
45
|
+
* @param options Optional month / fiscalYear metadata.
|
|
46
|
+
* @returns TemplateResult with final path and counts.
|
|
47
|
+
*/
|
|
48
|
+
export declare function generateNetSuiteTemplate(outputPath: string, options?: GenerateNetSuiteTemplateOptions): Promise<TemplateResult>;
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import ExcelJS from 'exceljs';
|
|
4
|
+
import { getSupabase } from '../supabase.js';
|
|
5
|
+
// --- Helpers ---
|
|
6
|
+
const MONTH_MAP = {
|
|
7
|
+
Jan: '01', Feb: '02', Mar: '03', Apr: '04',
|
|
8
|
+
May: '05', Jun: '06', Jul: '07', Aug: '08',
|
|
9
|
+
Sep: '09', Oct: '10', Nov: '11', Dec: '12',
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Convert "Dec 2025" → "12/1/2025" (NetSuite upload date format).
|
|
13
|
+
* Returns null if the input is absent or malformed.
|
|
14
|
+
*/
|
|
15
|
+
function monthToUploadDate(monthStr) {
|
|
16
|
+
const parts = monthStr.trim().split(' ');
|
|
17
|
+
if (parts.length !== 2)
|
|
18
|
+
return null;
|
|
19
|
+
const [name, year] = parts;
|
|
20
|
+
const num = MONTH_MAP[name];
|
|
21
|
+
if (!num)
|
|
22
|
+
return null;
|
|
23
|
+
return `${num}/1/${year}`;
|
|
24
|
+
}
|
|
25
|
+
const PAGE_SIZE = 1000;
|
|
26
|
+
/** Paginate all rows from a Supabase table, bypassing the 1000-row server cap. */
|
|
27
|
+
async function paginateAll(table, select, orderCol, filters) {
|
|
28
|
+
const sb = getSupabase('returnpro');
|
|
29
|
+
const all = [];
|
|
30
|
+
let from = 0;
|
|
31
|
+
while (true) {
|
|
32
|
+
let q = sb.from(table).select(select).order(orderCol).range(from, from + PAGE_SIZE - 1);
|
|
33
|
+
if (filters) {
|
|
34
|
+
for (const [col, val] of Object.entries(filters)) {
|
|
35
|
+
q = q.eq(col, val);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const { data, error } = await q;
|
|
39
|
+
if (error)
|
|
40
|
+
throw new Error(`Fetch ${table} failed: ${error.message}`);
|
|
41
|
+
if (!data || data.length === 0)
|
|
42
|
+
break;
|
|
43
|
+
all.push(...data);
|
|
44
|
+
if (data.length < PAGE_SIZE)
|
|
45
|
+
break;
|
|
46
|
+
from += PAGE_SIZE;
|
|
47
|
+
}
|
|
48
|
+
return all;
|
|
49
|
+
}
|
|
50
|
+
// --- Core ---
|
|
51
|
+
/**
|
|
52
|
+
* Generate a blank NetSuite upload template XLSX.
|
|
53
|
+
*
|
|
54
|
+
* The workbook mirrors the structure produced by dashboard-returnpro's
|
|
55
|
+
* `/api/admin/netsuite-template` route:
|
|
56
|
+
*
|
|
57
|
+
* Sheet 1 — Data Entry
|
|
58
|
+
* Row 1: headers — Master Program | Program ID | Date (Upload) | Date (Solution7) | <account_code>…
|
|
59
|
+
* Row 2+: one row per active program, upload/solution7 date cells pre-filled when `month` is given
|
|
60
|
+
*
|
|
61
|
+
* Sheet 2 — Account Reference
|
|
62
|
+
* Account Code | Account ID | NetSuite Label
|
|
63
|
+
*
|
|
64
|
+
* Sheet 3 — Instructions
|
|
65
|
+
* Key/value metadata + usage instructions
|
|
66
|
+
*
|
|
67
|
+
* Accounts are ordered by account_id; programs are ordered by
|
|
68
|
+
* master_program_name then program_code (matching the route).
|
|
69
|
+
*
|
|
70
|
+
* @param outputPath Destination file path (will be created or overwritten).
|
|
71
|
+
* @param options Optional month / fiscalYear metadata.
|
|
72
|
+
* @returns TemplateResult with final path and counts.
|
|
73
|
+
*/
|
|
74
|
+
export async function generateNetSuiteTemplate(outputPath, options) {
|
|
75
|
+
const resolvedPath = path.resolve(outputPath);
|
|
76
|
+
const month = options?.month;
|
|
77
|
+
const fiscalYear = options?.fiscalYear;
|
|
78
|
+
// Compute date strings if a month was provided
|
|
79
|
+
const uploadDate = month ? monthToUploadDate(month) : null;
|
|
80
|
+
if (month && !uploadDate) {
|
|
81
|
+
throw new Error(`Invalid month format: "${month}". Expected "MMM YYYY" (e.g. "Jan 2026").`);
|
|
82
|
+
}
|
|
83
|
+
// Solution7 date prefixes the month with a leading apostrophe so Excel treats it as text
|
|
84
|
+
const solution7Date = month ? `'${month}` : null;
|
|
85
|
+
// --- Fetch dimension data ---
|
|
86
|
+
const [programs, accounts] = await Promise.all([
|
|
87
|
+
paginateAll('dim_program_id', 'program_code,master_program_name,is_primary', 'master_program_name', { is_active: 'true' }),
|
|
88
|
+
paginateAll('dim_account', 'account_code,account_id,netsuite_label', 'account_id'),
|
|
89
|
+
]);
|
|
90
|
+
// Secondary sort: within same master_program_name, order by program_code
|
|
91
|
+
programs.sort((a, b) => a.master_program_name.localeCompare(b.master_program_name)
|
|
92
|
+
|| a.program_code.localeCompare(b.program_code));
|
|
93
|
+
// --- Build workbook ---
|
|
94
|
+
const workbook = new ExcelJS.Workbook();
|
|
95
|
+
workbook.creator = 'optimal-cli';
|
|
96
|
+
workbook.created = new Date();
|
|
97
|
+
// ------------------------------------------------------------------ //
|
|
98
|
+
// Sheet 1: Data Entry
|
|
99
|
+
// ------------------------------------------------------------------ //
|
|
100
|
+
const dataSheet = workbook.addWorksheet('Data Entry');
|
|
101
|
+
// Build header row
|
|
102
|
+
const fixedHeaders = ['Master Program', 'Program ID', 'Date (Upload)', 'Date (Solution7)'];
|
|
103
|
+
const accountHeaders = accounts.map(a => a.account_code);
|
|
104
|
+
const allHeaders = [...fixedHeaders, ...accountHeaders];
|
|
105
|
+
// Style the header row
|
|
106
|
+
const headerRow = dataSheet.addRow(allHeaders);
|
|
107
|
+
headerRow.font = { bold: true };
|
|
108
|
+
headerRow.fill = {
|
|
109
|
+
type: 'pattern',
|
|
110
|
+
pattern: 'solid',
|
|
111
|
+
fgColor: { argb: 'FFD9E1F2' }, // light blue
|
|
112
|
+
};
|
|
113
|
+
headerRow.alignment = { vertical: 'middle', horizontal: 'center' };
|
|
114
|
+
// Add one data row per program
|
|
115
|
+
for (const program of programs) {
|
|
116
|
+
const rowValues = [
|
|
117
|
+
program.master_program_name,
|
|
118
|
+
program.program_code,
|
|
119
|
+
uploadDate ?? null,
|
|
120
|
+
solution7Date ?? null,
|
|
121
|
+
// Account value cells start empty
|
|
122
|
+
...accounts.map(() => null),
|
|
123
|
+
];
|
|
124
|
+
dataSheet.addRow(rowValues);
|
|
125
|
+
}
|
|
126
|
+
// Freeze the header row and first two columns
|
|
127
|
+
dataSheet.views = [{ state: 'frozen', xSplit: 2, ySplit: 1 }];
|
|
128
|
+
// Set column widths
|
|
129
|
+
const colWidths = [
|
|
130
|
+
45, // Master Program
|
|
131
|
+
35, // Program ID
|
|
132
|
+
15, // Date (Upload)
|
|
133
|
+
15, // Date (Solution7)
|
|
134
|
+
...accounts.map(() => 12),
|
|
135
|
+
];
|
|
136
|
+
dataSheet.columns = allHeaders.map((header, i) => ({
|
|
137
|
+
header, // overwritten below via addRow — just sets .key
|
|
138
|
+
key: header,
|
|
139
|
+
width: colWidths[i],
|
|
140
|
+
}));
|
|
141
|
+
// Re-apply the styled header row (addRow above is already row 1; columns
|
|
142
|
+
// setter re-generates a header via key — replace it with our styled one)
|
|
143
|
+
// The columns setter inserts an extra header row if we set `header:`.
|
|
144
|
+
// Avoid that by only setting width/key after rows are added.
|
|
145
|
+
// Reset: clear columns, re-add rows fresh.
|
|
146
|
+
// ----- Rebuild cleanly to avoid double-header -----
|
|
147
|
+
dataSheet.spliceRows(1, dataSheet.rowCount); // clear all rows
|
|
148
|
+
// Re-add styled header
|
|
149
|
+
const hdr = dataSheet.addRow(allHeaders);
|
|
150
|
+
hdr.font = { bold: true };
|
|
151
|
+
hdr.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFD9E1F2' } };
|
|
152
|
+
hdr.alignment = { vertical: 'middle', horizontal: 'center' };
|
|
153
|
+
hdr.height = 18;
|
|
154
|
+
// Re-add data rows
|
|
155
|
+
for (const program of programs) {
|
|
156
|
+
const vals = [
|
|
157
|
+
program.master_program_name,
|
|
158
|
+
program.program_code,
|
|
159
|
+
uploadDate ?? null,
|
|
160
|
+
solution7Date ?? null,
|
|
161
|
+
...accounts.map(() => null),
|
|
162
|
+
];
|
|
163
|
+
dataSheet.addRow(vals);
|
|
164
|
+
}
|
|
165
|
+
// Column widths (set by index, 1-based)
|
|
166
|
+
const widths = [45, 35, 15, 15, ...accounts.map(() => 12)];
|
|
167
|
+
widths.forEach((w, i) => {
|
|
168
|
+
const col = dataSheet.getColumn(i + 1);
|
|
169
|
+
col.width = w;
|
|
170
|
+
});
|
|
171
|
+
dataSheet.views = [{ state: 'frozen', xSplit: 2, ySplit: 1 }];
|
|
172
|
+
// ------------------------------------------------------------------ //
|
|
173
|
+
// Sheet 2: Account Reference
|
|
174
|
+
// ------------------------------------------------------------------ //
|
|
175
|
+
const accountSheet = workbook.addWorksheet('Account Reference');
|
|
176
|
+
const acctHdr = accountSheet.addRow(['Account Code', 'Account ID', 'NetSuite Label']);
|
|
177
|
+
acctHdr.font = { bold: true };
|
|
178
|
+
acctHdr.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFD9E1F2' } };
|
|
179
|
+
for (const a of accounts) {
|
|
180
|
+
accountSheet.addRow([a.account_code, a.account_id, a.netsuite_label ?? '']);
|
|
181
|
+
}
|
|
182
|
+
accountSheet.getColumn(1).width = 20;
|
|
183
|
+
accountSheet.getColumn(2).width = 12;
|
|
184
|
+
accountSheet.getColumn(3).width = 60;
|
|
185
|
+
// ------------------------------------------------------------------ //
|
|
186
|
+
// Sheet 3: Instructions
|
|
187
|
+
// ------------------------------------------------------------------ //
|
|
188
|
+
const instrSheet = workbook.addWorksheet('Instructions');
|
|
189
|
+
const instrHdr = instrSheet.addRow(['Field', 'Value']);
|
|
190
|
+
instrHdr.font = { bold: true };
|
|
191
|
+
instrHdr.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFD9E1F2' } };
|
|
192
|
+
const instrRows = [
|
|
193
|
+
['Template Generated', new Date().toISOString()],
|
|
194
|
+
['Month', month ?? '(not specified)'],
|
|
195
|
+
['Upload Date Format', uploadDate ?? '(fill manually — format: MM/1/YYYY)'],
|
|
196
|
+
['Fiscal Year', fiscalYear ?? '(not specified)'],
|
|
197
|
+
['Total Programs', String(programs.length)],
|
|
198
|
+
['Total Account Columns', String(accounts.length)],
|
|
199
|
+
['Note', 'Excludes deprecated programs (is_active=false)'],
|
|
200
|
+
['', ''],
|
|
201
|
+
['Instructions', '1. Fill in account values using Solution7 formulas'],
|
|
202
|
+
['', '2. Use the "Date (Solution7)" column value in your formulas'],
|
|
203
|
+
['', '3. The "Date (Upload)" column has the format needed for stg_financials_raw'],
|
|
204
|
+
['', '4. Save and upload to the dashboard via Browse > stg_financials_raw'],
|
|
205
|
+
['', ''],
|
|
206
|
+
['Upload Format', 'When uploading, the system expects these columns:'],
|
|
207
|
+
['', ' - program_code (from "Program ID" column)'],
|
|
208
|
+
['', ' - master_program (from "Master Program" column)'],
|
|
209
|
+
['', ' - date (from "Date (Upload)" column)'],
|
|
210
|
+
['', ' - account_code (header row value)'],
|
|
211
|
+
['', ' - amount (cell value)'],
|
|
212
|
+
];
|
|
213
|
+
for (const [field, value] of instrRows) {
|
|
214
|
+
instrSheet.addRow([field, value]);
|
|
215
|
+
}
|
|
216
|
+
instrSheet.getColumn(1).width = 25;
|
|
217
|
+
instrSheet.getColumn(2).width = 70;
|
|
218
|
+
// ------------------------------------------------------------------ //
|
|
219
|
+
// Write file
|
|
220
|
+
// ------------------------------------------------------------------ //
|
|
221
|
+
const buffer = await workbook.xlsx.writeBuffer();
|
|
222
|
+
await writeFile(resolvedPath, Buffer.from(buffer));
|
|
223
|
+
return {
|
|
224
|
+
outputPath: resolvedPath,
|
|
225
|
+
accountCount: accounts.length,
|
|
226
|
+
programCount: programs.length,
|
|
227
|
+
month,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface IncomeStatementResult {
|
|
2
|
+
/** Period loaded, e.g. "2025-04" */
|
|
3
|
+
period: string;
|
|
4
|
+
/** Human-readable month label parsed from CSV, e.g. "Apr 2025" */
|
|
5
|
+
monthLabel: string;
|
|
6
|
+
/** Rows successfully upserted (inserted or updated) */
|
|
7
|
+
upserted: number;
|
|
8
|
+
/** Rows skipped due to parse errors */
|
|
9
|
+
skipped: number;
|
|
10
|
+
/** Non-fatal warnings / parse error messages */
|
|
11
|
+
warnings: string[];
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Upload a confirmed income statement CSV into `confirmed_income_statements`.
|
|
15
|
+
*
|
|
16
|
+
* Reads the CSV at `filePath`, parses it using the same logic as the
|
|
17
|
+
* dashboard's income-statement-parser, then upserts all account rows into
|
|
18
|
+
* ReturnPro Supabase (conflict resolution: account_code + period).
|
|
19
|
+
*
|
|
20
|
+
* @param filePath Absolute path to the NetSuite income statement CSV.
|
|
21
|
+
* @param userId User ID to associate with the upload (stored in `uploaded_by` if column exists; otherwise ignored).
|
|
22
|
+
* @param periodOverride Optional period override in "YYYY-MM" format.
|
|
23
|
+
* @returns IncomeStatementResult summary.
|
|
24
|
+
*/
|
|
25
|
+
export declare function uploadIncomeStatements(filePath: string, userId: string, periodOverride?: string): Promise<IncomeStatementResult>;
|