optimal-cli 1.0.1 → 1.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/.claude-plugin/marketplace.json +18 -0
- package/.claude-plugin/plugin.json +10 -0
- package/.env.example +17 -0
- package/CLAUDE.md +67 -0
- package/COMMANDS.md +264 -0
- package/PUBLISH.md +70 -0
- package/agents/content-ops.md +2 -2
- package/agents/financial-ops.md +2 -2
- package/agents/infra-ops.md +2 -2
- package/apps/.gitkeep +0 -0
- package/bin/optimal.ts +1418 -0
- package/docs/MIGRATION_NEEDED.md +37 -0
- package/docs/plans/.gitkeep +0 -0
- package/docs/plans/optimal-cli-config-registry-v1.md +71 -0
- package/hooks/.gitkeep +0 -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 +229 -0
- package/lib/config/schema.ts +58 -0
- package/lib/config.ts +247 -0
- package/lib/infra/.gitkeep +0 -0
- package/lib/infra/deploy.ts +70 -0
- package/lib/infra/migrate.ts +141 -0
- package/lib/kanban-obsidian.ts +232 -0
- package/lib/kanban-sync.ts +258 -0
- package/lib/kanban.ts +239 -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/obsidian-tasks.ts +231 -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/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 +5 -18
- package/pnpm-workspace.yaml +3 -0
- package/scripts/check-table.ts +24 -0
- package/scripts/create-tables.ts +94 -0
- package/scripts/migrate-kanban.sh +28 -0
- package/scripts/migrate-v2.ts +78 -0
- package/scripts/migrate.ts +79 -0
- package/scripts/run-migration.ts +59 -0
- package/scripts/seed-board.ts +203 -0
- package/scripts/test-kanban.ts +21 -0
- package/skills/audit-financials/SKILL.md +33 -0
- package/skills/board-create/SKILL.md +28 -0
- package/skills/board-update/SKILL.md +27 -0
- package/skills/board-view/SKILL.md +27 -0
- package/skills/delete-batch/SKILL.md +77 -0
- package/skills/deploy/SKILL.md +40 -0
- package/skills/diagnose-months/SKILL.md +68 -0
- package/skills/distribute-newsletter/SKILL.md +58 -0
- package/skills/export-budget/SKILL.md +44 -0
- package/skills/export-kpis/SKILL.md +52 -0
- package/skills/generate-netsuite-template/SKILL.md +51 -0
- package/skills/generate-newsletter/SKILL.md +53 -0
- package/skills/generate-newsletter-insurance/SKILL.md +59 -0
- package/skills/generate-social-posts/SKILL.md +67 -0
- package/skills/health-check/SKILL.md +42 -0
- package/skills/ingest-transactions/SKILL.md +51 -0
- package/skills/manage-cms/SKILL.md +50 -0
- package/skills/manage-scenarios/SKILL.md +83 -0
- package/skills/migrate-db/SKILL.md +79 -0
- package/skills/preview-newsletter/SKILL.md +50 -0
- package/skills/project-budget/SKILL.md +60 -0
- package/skills/publish-blog/SKILL.md +70 -0
- package/skills/publish-social-posts/SKILL.md +70 -0
- package/skills/rate-anomalies/SKILL.md +62 -0
- package/skills/scrape-ads/SKILL.md +49 -0
- package/skills/stamp-transactions/SKILL.md +62 -0
- package/skills/upload-income-statements/SKILL.md +54 -0
- package/skills/upload-netsuite/SKILL.md +56 -0
- package/skills/upload-r1/SKILL.md +45 -0
- package/supabase/.temp/cli-latest +1 -0
- package/supabase/migrations/.gitkeep +0 -0
- package/supabase/migrations/20250305000001_create_agent_configs.sql +36 -0
- package/supabase/migrations/20260305111300_create_cli_config_registry.sql +22 -0
- package/supabase/migrations/20260306195000_create_kanban_tables.sql +97 -0
- package/tests/config-command-smoke.test.ts +395 -0
- package/tests/config-registry.test.ts +173 -0
- package/tsconfig.json +19 -0
- package/agents/profiles.json +0 -5
- package/dist/bin/optimal.d.ts +0 -2
- package/dist/bin/optimal.js +0 -1590
- package/dist/lib/assets/index.d.ts +0 -79
- package/dist/lib/assets/index.js +0 -153
- package/dist/lib/assets.d.ts +0 -20
- package/dist/lib/assets.js +0 -112
- package/dist/lib/auth/index.d.ts +0 -83
- package/dist/lib/auth/index.js +0 -146
- package/dist/lib/board/index.d.ts +0 -39
- package/dist/lib/board/index.js +0 -285
- package/dist/lib/board/types.d.ts +0 -111
- package/dist/lib/board/types.js +0 -1
- package/dist/lib/bot/claim.d.ts +0 -3
- package/dist/lib/bot/claim.js +0 -20
- package/dist/lib/bot/coordinator.d.ts +0 -27
- package/dist/lib/bot/coordinator.js +0 -178
- package/dist/lib/bot/heartbeat.d.ts +0 -6
- package/dist/lib/bot/heartbeat.js +0 -30
- package/dist/lib/bot/index.d.ts +0 -9
- package/dist/lib/bot/index.js +0 -6
- package/dist/lib/bot/protocol.d.ts +0 -12
- package/dist/lib/bot/protocol.js +0 -74
- package/dist/lib/bot/reporter.d.ts +0 -3
- package/dist/lib/bot/reporter.js +0 -27
- package/dist/lib/bot/skills.d.ts +0 -26
- package/dist/lib/bot/skills.js +0 -69
- 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/registry.d.ts +0 -17
- package/dist/lib/config/registry.js +0 -182
- package/dist/lib/config/schema.d.ts +0 -31
- package/dist/lib/config/schema.js +0 -25
- package/dist/lib/config.d.ts +0 -55
- package/dist/lib/config.js +0 -206
- package/dist/lib/errors.d.ts +0 -25
- package/dist/lib/errors.js +0 -91
- package/dist/lib/format.d.ts +0 -28
- package/dist/lib/format.js +0 -98
- 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/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/returnpro/validate.d.ts +0 -37
- package/dist/lib/returnpro/validate.js +0 -124
- package/dist/lib/social/meta.d.ts +0 -90
- package/dist/lib/social/meta.js +0 -160
- 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
- package/docs/CLI-REFERENCE.md +0 -361
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Supabase Migration Required
|
|
2
|
+
|
|
3
|
+
The `cli_config_registry` table needs to be created in your Supabase database for the config sync feature to work.
|
|
4
|
+
|
|
5
|
+
## Manual Steps
|
|
6
|
+
|
|
7
|
+
1. Go to your Supabase project: https://app.supabase.com/project/_/sql
|
|
8
|
+
2. Run this SQL:
|
|
9
|
+
|
|
10
|
+
```sql
|
|
11
|
+
create table if not exists public.cli_config_registry (
|
|
12
|
+
id uuid primary key default gen_random_uuid(),
|
|
13
|
+
owner text not null,
|
|
14
|
+
profile text not null default 'default',
|
|
15
|
+
config_version text not null,
|
|
16
|
+
payload jsonb not null,
|
|
17
|
+
payload_hash text not null,
|
|
18
|
+
source text not null default 'optimal-cli',
|
|
19
|
+
updated_by text,
|
|
20
|
+
updated_at timestamptz not null default now(),
|
|
21
|
+
created_at timestamptz not null default now(),
|
|
22
|
+
unique (owner, profile)
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
create index if not exists idx_cli_config_registry_owner_profile
|
|
26
|
+
on public.cli_config_registry (owner, profile);
|
|
27
|
+
|
|
28
|
+
create index if not exists idx_cli_config_registry_updated_at
|
|
29
|
+
on public.cli_config_registry (updated_at desc);
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
3. After running the migration, the `optimal config sync push/pull` commands will work.
|
|
33
|
+
|
|
34
|
+
## File Location
|
|
35
|
+
|
|
36
|
+
The migration file is also available at:
|
|
37
|
+
`optimal-cli/supabase/migrations/20260305111300_create_cli_config_registry.sql`
|
|
File without changes
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# optimal-cli shared config registry v1 (draft)
|
|
2
|
+
|
|
3
|
+
## objective
|
|
4
|
+
define a repeatable config-sharing model for `optimal-cli` with versioned schema, supabase-backed sync, and clear command surface.
|
|
5
|
+
|
|
6
|
+
## v1 scope
|
|
7
|
+
- single-user + team-ready config profile storage
|
|
8
|
+
- deterministic import/export format
|
|
9
|
+
- pull/push sync with conflict visibility
|
|
10
|
+
- schema migration path (`version` field)
|
|
11
|
+
|
|
12
|
+
## config schema (v1)
|
|
13
|
+
```json
|
|
14
|
+
{
|
|
15
|
+
"version": "1.0.0",
|
|
16
|
+
"profile": {
|
|
17
|
+
"name": "default",
|
|
18
|
+
"owner": "clenisa",
|
|
19
|
+
"updated_at": "2026-03-05T04:40:00-05:00"
|
|
20
|
+
},
|
|
21
|
+
"providers": {
|
|
22
|
+
"supabase": {
|
|
23
|
+
"project_ref": "<ref>",
|
|
24
|
+
"url": "<url>",
|
|
25
|
+
"anon_key_present": true
|
|
26
|
+
},
|
|
27
|
+
"strapi": {
|
|
28
|
+
"base_url": "https://strapi.op-hub.com",
|
|
29
|
+
"token_present": true
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"defaults": {
|
|
33
|
+
"brand": "CRE-11TRUST",
|
|
34
|
+
"timezone": "America/New_York"
|
|
35
|
+
},
|
|
36
|
+
"features": {
|
|
37
|
+
"cms": true,
|
|
38
|
+
"tasks": true,
|
|
39
|
+
"deploy": false
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## supabase model (proposed)
|
|
45
|
+
`cli_config_registry`
|
|
46
|
+
- `id uuid pk`
|
|
47
|
+
- `owner text not null`
|
|
48
|
+
- `profile_name text not null`
|
|
49
|
+
- `schema_version text not null`
|
|
50
|
+
- `config_json jsonb not null`
|
|
51
|
+
- `config_hash text not null`
|
|
52
|
+
- `updated_at timestamptz not null default now()`
|
|
53
|
+
- unique `(owner, profile_name)`
|
|
54
|
+
|
|
55
|
+
## command surface (v1)
|
|
56
|
+
- `optimal config init [--profile default]`
|
|
57
|
+
- `optimal config export --out ./optimal.config.json`
|
|
58
|
+
- `optimal config import --file ./optimal.config.json [--merge|--replace]`
|
|
59
|
+
- `optimal config sync pull [--profile default]`
|
|
60
|
+
- `optimal config sync push [--profile default] [--force]`
|
|
61
|
+
- `optimal config doctor`
|
|
62
|
+
|
|
63
|
+
## conflict model
|
|
64
|
+
- compare local `config_hash` vs remote `config_hash`
|
|
65
|
+
- if diverged and no `--force`, abort with resolution hints
|
|
66
|
+
- write pull/merge decisions to local audit log (`~/.optimal/config-history.log`)
|
|
67
|
+
|
|
68
|
+
## next implementation step
|
|
69
|
+
1. add `lib/config/schema.ts` + zod validator
|
|
70
|
+
2. add `bin/optimal.ts` `config` command group with `doctor` + `export`
|
|
71
|
+
3. scaffold supabase read/write adapter in `lib/config/registry.ts`
|
package/hooks/.gitkeep
ADDED
|
File without changes
|
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Budget projection calculator for FY26 planning
|
|
3
|
+
*
|
|
4
|
+
* Ported from wes-dashboard/src/lib/projections/calculator.ts
|
|
5
|
+
* Pure TypeScript — no React, no framework deps.
|
|
6
|
+
*
|
|
7
|
+
* Supports two adjustment types:
|
|
8
|
+
* - Percentage: projected = actual * (1 + rate/100)
|
|
9
|
+
* - Flat: projected = actual + flatAmount
|
|
10
|
+
*
|
|
11
|
+
* Supports both unit AND average retail projections for revenue forecasting.
|
|
12
|
+
*
|
|
13
|
+
* Data sources:
|
|
14
|
+
* - Supabase `fpa_wes_imports` table (ReturnPro instance)
|
|
15
|
+
* - JSON file from stdin or --file flag
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { getSupabase } from '../supabase.js'
|
|
19
|
+
|
|
20
|
+
// --- Types ---
|
|
21
|
+
|
|
22
|
+
export interface CheckedInUnitsSummary {
|
|
23
|
+
programCode: string
|
|
24
|
+
masterProgram: string
|
|
25
|
+
masterProgramId: number | null
|
|
26
|
+
clientId: number | null
|
|
27
|
+
clientName: string
|
|
28
|
+
unitCount: number
|
|
29
|
+
countMethod: 'Unit' | 'Pallet'
|
|
30
|
+
avgRetail?: number
|
|
31
|
+
monthLabel?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ProjectionEntry {
|
|
35
|
+
programCode: string
|
|
36
|
+
masterProgram: string
|
|
37
|
+
masterProgramId: number | null
|
|
38
|
+
clientId: number | null
|
|
39
|
+
clientName: string
|
|
40
|
+
// Unit projections
|
|
41
|
+
actualUnits: number
|
|
42
|
+
adjustmentType: 'percentage' | 'flat'
|
|
43
|
+
adjustmentValue: number
|
|
44
|
+
projectedUnits: number
|
|
45
|
+
// Average retail projections
|
|
46
|
+
avgRetail?: number
|
|
47
|
+
avgRetailAdjustmentType: 'percentage' | 'flat'
|
|
48
|
+
avgRetailAdjustmentValue: number
|
|
49
|
+
projectedAvgRetail?: number
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ProjectionInput {
|
|
53
|
+
actualUnits: number
|
|
54
|
+
adjustmentType: 'percentage' | 'flat'
|
|
55
|
+
adjustmentValue: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ProjectionTotals {
|
|
59
|
+
totalActual: number
|
|
60
|
+
totalProjected: number
|
|
61
|
+
percentageChange: number
|
|
62
|
+
absoluteChange: number
|
|
63
|
+
actualRevenue: number
|
|
64
|
+
projectedRevenue: number
|
|
65
|
+
revenueChange: number
|
|
66
|
+
revenuePercentChange: number
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// --- Core calculation functions ---
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Calculate projected units based on adjustment type and value.
|
|
73
|
+
*/
|
|
74
|
+
export function calculateProjection(input: ProjectionInput): number {
|
|
75
|
+
const { actualUnits, adjustmentType, adjustmentValue } = input
|
|
76
|
+
|
|
77
|
+
if (adjustmentType === 'percentage') {
|
|
78
|
+
return Math.round(actualUnits * (1 + adjustmentValue / 100))
|
|
79
|
+
} else {
|
|
80
|
+
return Math.max(0, actualUnits + adjustmentValue)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Calculate projected average retail price.
|
|
86
|
+
*/
|
|
87
|
+
export function calculateAvgRetailProjection(
|
|
88
|
+
actualAvgRetail: number | undefined,
|
|
89
|
+
adjustmentType: 'percentage' | 'flat',
|
|
90
|
+
adjustmentValue: number,
|
|
91
|
+
): number | undefined {
|
|
92
|
+
if (actualAvgRetail == null) return undefined
|
|
93
|
+
|
|
94
|
+
if (adjustmentType === 'percentage') {
|
|
95
|
+
return actualAvgRetail * (1 + adjustmentValue / 100)
|
|
96
|
+
} else {
|
|
97
|
+
return Math.max(0, actualAvgRetail + adjustmentValue)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Convert checked-in units summary to projection entries with default values (0% change).
|
|
103
|
+
*/
|
|
104
|
+
export function initializeProjections(
|
|
105
|
+
summary: CheckedInUnitsSummary[],
|
|
106
|
+
): ProjectionEntry[] {
|
|
107
|
+
return (summary ?? []).map((item) => {
|
|
108
|
+
const units = typeof item.unitCount === 'number' ? item.unitCount : 0
|
|
109
|
+
const retail = typeof item.avgRetail === 'number' ? item.avgRetail : undefined
|
|
110
|
+
return {
|
|
111
|
+
programCode: item.programCode ?? '',
|
|
112
|
+
masterProgram: item.masterProgram ?? '',
|
|
113
|
+
masterProgramId: item.masterProgramId ?? null,
|
|
114
|
+
clientId: item.clientId ?? null,
|
|
115
|
+
clientName: item.clientName ?? 'Unknown',
|
|
116
|
+
actualUnits: units,
|
|
117
|
+
adjustmentType: 'percentage' as const,
|
|
118
|
+
adjustmentValue: 0,
|
|
119
|
+
projectedUnits: units,
|
|
120
|
+
avgRetail: retail,
|
|
121
|
+
avgRetailAdjustmentType: 'percentage' as const,
|
|
122
|
+
avgRetailAdjustmentValue: 0,
|
|
123
|
+
projectedAvgRetail: retail,
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Update a single projection entry's units.
|
|
130
|
+
*/
|
|
131
|
+
export function updateProjection(
|
|
132
|
+
entry: ProjectionEntry,
|
|
133
|
+
adjustmentType: 'percentage' | 'flat',
|
|
134
|
+
adjustmentValue: number,
|
|
135
|
+
): ProjectionEntry {
|
|
136
|
+
const projectedUnits = calculateProjection({
|
|
137
|
+
actualUnits: entry.actualUnits,
|
|
138
|
+
adjustmentType,
|
|
139
|
+
adjustmentValue,
|
|
140
|
+
})
|
|
141
|
+
return { ...entry, adjustmentType, adjustmentValue, projectedUnits }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Update a single projection entry's average retail.
|
|
146
|
+
*/
|
|
147
|
+
export function updateAvgRetailProjection(
|
|
148
|
+
entry: ProjectionEntry,
|
|
149
|
+
adjustmentType: 'percentage' | 'flat',
|
|
150
|
+
adjustmentValue: number,
|
|
151
|
+
): ProjectionEntry {
|
|
152
|
+
const projectedAvgRetail = calculateAvgRetailProjection(
|
|
153
|
+
entry.avgRetail,
|
|
154
|
+
adjustmentType,
|
|
155
|
+
adjustmentValue,
|
|
156
|
+
)
|
|
157
|
+
return {
|
|
158
|
+
...entry,
|
|
159
|
+
avgRetailAdjustmentType: adjustmentType,
|
|
160
|
+
avgRetailAdjustmentValue: adjustmentValue,
|
|
161
|
+
projectedAvgRetail,
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Apply a uniform unit adjustment to all projections.
|
|
167
|
+
*/
|
|
168
|
+
export function applyUniformAdjustment(
|
|
169
|
+
projections: ProjectionEntry[],
|
|
170
|
+
adjustmentType: 'percentage' | 'flat',
|
|
171
|
+
adjustmentValue: number,
|
|
172
|
+
): ProjectionEntry[] {
|
|
173
|
+
return projections.map((entry) =>
|
|
174
|
+
updateProjection(entry, adjustmentType, adjustmentValue),
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Apply a uniform avg retail adjustment to all projections.
|
|
180
|
+
*/
|
|
181
|
+
export function applyUniformAvgRetailAdjustment(
|
|
182
|
+
projections: ProjectionEntry[],
|
|
183
|
+
adjustmentType: 'percentage' | 'flat',
|
|
184
|
+
adjustmentValue: number,
|
|
185
|
+
): ProjectionEntry[] {
|
|
186
|
+
return projections.map((entry) =>
|
|
187
|
+
updateAvgRetailProjection(entry, adjustmentType, adjustmentValue),
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Calculate totals for projection summary including revenue.
|
|
193
|
+
*/
|
|
194
|
+
export function calculateTotals(projections: ProjectionEntry[]): ProjectionTotals {
|
|
195
|
+
const totalActual = projections.reduce((sum, p) => sum + p.actualUnits, 0)
|
|
196
|
+
const totalProjected = projections.reduce((sum, p) => sum + p.projectedUnits, 0)
|
|
197
|
+
const absoluteChange = totalProjected - totalActual
|
|
198
|
+
const percentageChange =
|
|
199
|
+
totalActual > 0
|
|
200
|
+
? ((totalProjected - totalActual) / totalActual) * 100
|
|
201
|
+
: 0
|
|
202
|
+
|
|
203
|
+
const actualRevenue = projections.reduce((sum, p) => {
|
|
204
|
+
if (p.avgRetail != null) return sum + p.actualUnits * p.avgRetail
|
|
205
|
+
return sum
|
|
206
|
+
}, 0)
|
|
207
|
+
|
|
208
|
+
const projectedRevenue = projections.reduce((sum, p) => {
|
|
209
|
+
if (p.projectedAvgRetail != null)
|
|
210
|
+
return sum + p.projectedUnits * p.projectedAvgRetail
|
|
211
|
+
return sum
|
|
212
|
+
}, 0)
|
|
213
|
+
|
|
214
|
+
const revenueChange = projectedRevenue - actualRevenue
|
|
215
|
+
const revenuePercentChange =
|
|
216
|
+
actualRevenue > 0
|
|
217
|
+
? ((projectedRevenue - actualRevenue) / actualRevenue) * 100
|
|
218
|
+
: 0
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
totalActual,
|
|
222
|
+
totalProjected,
|
|
223
|
+
percentageChange,
|
|
224
|
+
absoluteChange,
|
|
225
|
+
actualRevenue,
|
|
226
|
+
projectedRevenue,
|
|
227
|
+
revenueChange,
|
|
228
|
+
revenuePercentChange,
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Group projections by client name.
|
|
234
|
+
*/
|
|
235
|
+
export function groupProjectionsByClient(
|
|
236
|
+
projections: ProjectionEntry[],
|
|
237
|
+
): Map<string, ProjectionEntry[]> {
|
|
238
|
+
const groups = new Map<string, ProjectionEntry[]>()
|
|
239
|
+
for (const p of projections) {
|
|
240
|
+
const list = groups.get(p.clientName) ?? []
|
|
241
|
+
list.push(p)
|
|
242
|
+
groups.set(p.clientName, list)
|
|
243
|
+
}
|
|
244
|
+
return groups
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Export projections to CSV format with unit + avgRetail + inventory value data.
|
|
249
|
+
*/
|
|
250
|
+
export function exportToCSV(projections: ProjectionEntry[]): string {
|
|
251
|
+
const headers = [
|
|
252
|
+
'Program Code',
|
|
253
|
+
'Master Program',
|
|
254
|
+
'Client',
|
|
255
|
+
'2025 Actual Units',
|
|
256
|
+
'Unit Adj Type',
|
|
257
|
+
'Unit Adj Value',
|
|
258
|
+
'2026 Projected Units',
|
|
259
|
+
'Unit Change',
|
|
260
|
+
'Unit Change %',
|
|
261
|
+
'2025 Avg Retail',
|
|
262
|
+
'Retail Adj Type',
|
|
263
|
+
'Retail Adj Value',
|
|
264
|
+
'2026 Projected Retail',
|
|
265
|
+
'Retail Change',
|
|
266
|
+
'Retail Change %',
|
|
267
|
+
'2025 Inventory Value',
|
|
268
|
+
'2026 Projected Inv. Value',
|
|
269
|
+
'Inv. Value Change',
|
|
270
|
+
'Inv. Value Change %',
|
|
271
|
+
]
|
|
272
|
+
|
|
273
|
+
const rows = projections.map((p) => {
|
|
274
|
+
const unitChange = p.projectedUnits - p.actualUnits
|
|
275
|
+
const unitChangePct =
|
|
276
|
+
p.actualUnits > 0
|
|
277
|
+
? ((unitChange / p.actualUnits) * 100).toFixed(1)
|
|
278
|
+
: '0.0'
|
|
279
|
+
|
|
280
|
+
const retailChange = (p.projectedAvgRetail ?? 0) - (p.avgRetail ?? 0)
|
|
281
|
+
const retailChangePct =
|
|
282
|
+
p.avgRetail != null && p.avgRetail > 0
|
|
283
|
+
? ((retailChange / p.avgRetail) * 100).toFixed(1)
|
|
284
|
+
: '0.0'
|
|
285
|
+
|
|
286
|
+
const actualRev = p.avgRetail != null ? p.actualUnits * p.avgRetail : 0
|
|
287
|
+
const projRev =
|
|
288
|
+
p.projectedAvgRetail != null
|
|
289
|
+
? p.projectedUnits * p.projectedAvgRetail
|
|
290
|
+
: 0
|
|
291
|
+
const revChange = projRev - actualRev
|
|
292
|
+
const revChangePct =
|
|
293
|
+
actualRev > 0 ? ((revChange / actualRev) * 100).toFixed(1) : '0.0'
|
|
294
|
+
|
|
295
|
+
return [
|
|
296
|
+
csvEscape(p.programCode),
|
|
297
|
+
csvEscape(p.masterProgram),
|
|
298
|
+
csvEscape(p.clientName),
|
|
299
|
+
p.actualUnits,
|
|
300
|
+
p.adjustmentType,
|
|
301
|
+
p.adjustmentType === 'percentage'
|
|
302
|
+
? `${p.adjustmentValue}%`
|
|
303
|
+
: p.adjustmentValue,
|
|
304
|
+
p.projectedUnits,
|
|
305
|
+
unitChange,
|
|
306
|
+
`${unitChangePct}%`,
|
|
307
|
+
p.avgRetail != null ? `$${p.avgRetail.toFixed(2)}` : '',
|
|
308
|
+
p.avgRetailAdjustmentType,
|
|
309
|
+
p.avgRetailAdjustmentType === 'percentage'
|
|
310
|
+
? `${p.avgRetailAdjustmentValue}%`
|
|
311
|
+
: `$${p.avgRetailAdjustmentValue}`,
|
|
312
|
+
p.projectedAvgRetail != null
|
|
313
|
+
? `$${p.projectedAvgRetail.toFixed(2)}`
|
|
314
|
+
: '',
|
|
315
|
+
p.avgRetail != null ? `$${retailChange.toFixed(2)}` : '',
|
|
316
|
+
p.avgRetail != null ? `${retailChangePct}%` : '',
|
|
317
|
+
actualRev > 0 ? `$${actualRev.toFixed(2)}` : '',
|
|
318
|
+
projRev > 0 ? `$${projRev.toFixed(2)}` : '',
|
|
319
|
+
actualRev > 0 ? `$${revChange.toFixed(2)}` : '',
|
|
320
|
+
actualRev > 0 ? `${revChangePct}%` : '',
|
|
321
|
+
].join(',')
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
return [headers.join(','), ...rows].join('\n')
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// --- Data fetching ---
|
|
328
|
+
|
|
329
|
+
const PAGE_SIZE = 1000
|
|
330
|
+
|
|
331
|
+
interface WesImportRow {
|
|
332
|
+
program_code: string | null
|
|
333
|
+
master_program_id: number
|
|
334
|
+
actual_units_prior_year: number | null
|
|
335
|
+
projected_units: number | null
|
|
336
|
+
avg_retail_prior_year: number | null
|
|
337
|
+
projected_avg_retail: number | null
|
|
338
|
+
unit_adj_type: string | null
|
|
339
|
+
unit_adj_value: number | null
|
|
340
|
+
retail_adj_type: string | null
|
|
341
|
+
retail_adj_value: number | null
|
|
342
|
+
dim_master_program: {
|
|
343
|
+
master_name: string
|
|
344
|
+
client_id: number | null
|
|
345
|
+
dim_client: { client_name: string } | null
|
|
346
|
+
} | null
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Fetch FY25 actuals from fpa_wes_imports on the ReturnPro Supabase instance.
|
|
351
|
+
* Aggregates across all months for a given fiscal year and user,
|
|
352
|
+
* returning one CheckedInUnitsSummary per master program.
|
|
353
|
+
*/
|
|
354
|
+
export async function fetchWesImports(options?: {
|
|
355
|
+
fiscalYear?: number
|
|
356
|
+
userId?: string
|
|
357
|
+
}): Promise<CheckedInUnitsSummary[]> {
|
|
358
|
+
const sb = getSupabase('returnpro')
|
|
359
|
+
const fy = options?.fiscalYear ?? 2025
|
|
360
|
+
|
|
361
|
+
const summaryMap = new Map<
|
|
362
|
+
number,
|
|
363
|
+
{
|
|
364
|
+
programCode: string
|
|
365
|
+
masterProgram: string
|
|
366
|
+
masterProgramId: number
|
|
367
|
+
clientId: number | null
|
|
368
|
+
clientName: string
|
|
369
|
+
totalUnits: number
|
|
370
|
+
retailSum: number
|
|
371
|
+
retailCount: number
|
|
372
|
+
}
|
|
373
|
+
>()
|
|
374
|
+
|
|
375
|
+
let from = 0
|
|
376
|
+
while (true) {
|
|
377
|
+
let query = sb
|
|
378
|
+
.from('fpa_wes_imports')
|
|
379
|
+
.select(
|
|
380
|
+
`
|
|
381
|
+
program_code,
|
|
382
|
+
master_program_id,
|
|
383
|
+
actual_units_prior_year,
|
|
384
|
+
projected_units,
|
|
385
|
+
avg_retail_prior_year,
|
|
386
|
+
projected_avg_retail,
|
|
387
|
+
unit_adj_type,
|
|
388
|
+
unit_adj_value,
|
|
389
|
+
retail_adj_type,
|
|
390
|
+
retail_adj_value,
|
|
391
|
+
dim_master_program(master_name, client_id, dim_client(client_name))
|
|
392
|
+
`,
|
|
393
|
+
)
|
|
394
|
+
.eq('fiscal_year', fy)
|
|
395
|
+
.order('master_program_id')
|
|
396
|
+
.range(from, from + PAGE_SIZE - 1)
|
|
397
|
+
|
|
398
|
+
if (options?.userId) {
|
|
399
|
+
query = query.eq('user_id', options.userId)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const { data, error } = await query
|
|
403
|
+
|
|
404
|
+
if (error)
|
|
405
|
+
throw new Error(`Fetch fpa_wes_imports failed: ${error.message}`)
|
|
406
|
+
if (!data || data.length === 0) break
|
|
407
|
+
|
|
408
|
+
for (const row of data as unknown as WesImportRow[]) {
|
|
409
|
+
const mpId = row.master_program_id
|
|
410
|
+
const existing = summaryMap.get(mpId)
|
|
411
|
+
const units = row.actual_units_prior_year ?? row.projected_units ?? 0
|
|
412
|
+
const retail = row.avg_retail_prior_year ?? null
|
|
413
|
+
|
|
414
|
+
if (existing) {
|
|
415
|
+
existing.totalUnits += units
|
|
416
|
+
if (retail != null) {
|
|
417
|
+
existing.retailSum += retail
|
|
418
|
+
existing.retailCount += 1
|
|
419
|
+
}
|
|
420
|
+
} else {
|
|
421
|
+
const dim = row.dim_master_program
|
|
422
|
+
summaryMap.set(mpId, {
|
|
423
|
+
programCode: row.program_code ?? '',
|
|
424
|
+
masterProgram: dim?.master_name ?? '',
|
|
425
|
+
masterProgramId: mpId,
|
|
426
|
+
clientId: dim?.client_id ?? null,
|
|
427
|
+
clientName: dim?.dim_client?.client_name ?? 'Unknown',
|
|
428
|
+
totalUnits: units,
|
|
429
|
+
retailSum: retail ?? 0,
|
|
430
|
+
retailCount: retail != null ? 1 : 0,
|
|
431
|
+
})
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (data.length < PAGE_SIZE) break
|
|
436
|
+
from += PAGE_SIZE
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const results: CheckedInUnitsSummary[] = []
|
|
440
|
+
for (const entry of summaryMap.values()) {
|
|
441
|
+
results.push({
|
|
442
|
+
programCode: entry.programCode,
|
|
443
|
+
masterProgram: entry.masterProgram,
|
|
444
|
+
masterProgramId: entry.masterProgramId,
|
|
445
|
+
clientId: entry.clientId,
|
|
446
|
+
clientName: entry.clientName,
|
|
447
|
+
unitCount: entry.totalUnits,
|
|
448
|
+
countMethod: 'Unit',
|
|
449
|
+
avgRetail:
|
|
450
|
+
entry.retailCount > 0
|
|
451
|
+
? entry.retailSum / entry.retailCount
|
|
452
|
+
: undefined,
|
|
453
|
+
})
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
results.sort((a, b) => a.clientName.localeCompare(b.clientName) || a.masterProgram.localeCompare(b.masterProgram))
|
|
457
|
+
return results
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Parse a JSON file (array of CheckedInUnitsSummary) as an alternative data source.
|
|
462
|
+
* Accepts raw JSON string (e.g., from stdin or file read).
|
|
463
|
+
*/
|
|
464
|
+
export function parseSummaryFromJson(json: string): CheckedInUnitsSummary[] {
|
|
465
|
+
const data = JSON.parse(json) as CheckedInUnitsSummary[]
|
|
466
|
+
if (!Array.isArray(data)) {
|
|
467
|
+
throw new Error('Expected a JSON array of CheckedInUnitsSummary objects')
|
|
468
|
+
}
|
|
469
|
+
return data
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// --- Formatting helpers ---
|
|
473
|
+
|
|
474
|
+
function csvEscape(s: string): string {
|
|
475
|
+
if (s.includes(',') || s.includes('"') || s.includes('\n')) {
|
|
476
|
+
return `"${s.replace(/"/g, '""')}"`
|
|
477
|
+
}
|
|
478
|
+
return s
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function fmtCompact(n: number): string {
|
|
482
|
+
const abs = Math.abs(n)
|
|
483
|
+
const sign = n < 0 ? '-' : ''
|
|
484
|
+
if (abs >= 1_000_000) return `${sign}$${(abs / 1_000_000).toFixed(1)}M`
|
|
485
|
+
if (abs >= 1_000) return `${sign}$${(abs / 1_000).toFixed(1)}K`
|
|
486
|
+
return `${sign}$${abs.toFixed(0)}`
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function fmtUnits(n: number): string {
|
|
490
|
+
if (Math.abs(n) >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`
|
|
491
|
+
if (Math.abs(n) >= 1_000) return `${(n / 1_000).toFixed(1)}K`
|
|
492
|
+
return String(n)
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function fmtDelta(pct: number): string {
|
|
496
|
+
const arrow = pct >= 0 ? '\u2191' : '\u2193'
|
|
497
|
+
return `${arrow}${pct >= 0 ? '+' : ''}${pct.toFixed(1)}%`
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Format projections as a Bloomberg-dense markdown table.
|
|
502
|
+
*/
|
|
503
|
+
export function formatProjectionTable(projections: ProjectionEntry[]): string {
|
|
504
|
+
if (projections.length === 0) return 'No projection data.'
|
|
505
|
+
|
|
506
|
+
const totals = calculateTotals(projections)
|
|
507
|
+
const lines: string[] = []
|
|
508
|
+
|
|
509
|
+
// Summary header
|
|
510
|
+
lines.push(
|
|
511
|
+
`FY25 Actual: ${fmtUnits(totals.totalActual)} units | FY26 Projected: ${fmtUnits(totals.totalProjected)} units | ${fmtDelta(totals.percentageChange)}`,
|
|
512
|
+
)
|
|
513
|
+
if (totals.actualRevenue > 0) {
|
|
514
|
+
lines.push(
|
|
515
|
+
`Revenue: ${fmtCompact(totals.actualRevenue)} -> ${fmtCompact(totals.projectedRevenue)} | ${fmtDelta(totals.revenuePercentChange)}`,
|
|
516
|
+
)
|
|
517
|
+
}
|
|
518
|
+
lines.push('')
|
|
519
|
+
|
|
520
|
+
// Table
|
|
521
|
+
lines.push(
|
|
522
|
+
'| Client | Program | FY25 Units | FY26 Units | Delta | Avg Retail | Proj Retail | Rev Delta |',
|
|
523
|
+
)
|
|
524
|
+
lines.push(
|
|
525
|
+
'|--------|---------|------------|------------|-------|------------|-------------|-----------|',
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
for (const p of projections) {
|
|
529
|
+
const unitDelta = p.projectedUnits - p.actualUnits
|
|
530
|
+
const unitPct =
|
|
531
|
+
p.actualUnits > 0
|
|
532
|
+
? ((unitDelta / p.actualUnits) * 100).toFixed(1)
|
|
533
|
+
: '0.0'
|
|
534
|
+
const deltaStr = `${unitDelta >= 0 ? '+' : ''}${fmtUnits(unitDelta)} (${unitPct}%)`
|
|
535
|
+
|
|
536
|
+
const retailStr =
|
|
537
|
+
p.avgRetail != null ? `$${p.avgRetail.toFixed(2)}` : '-'
|
|
538
|
+
const projRetailStr =
|
|
539
|
+
p.projectedAvgRetail != null
|
|
540
|
+
? `$${p.projectedAvgRetail.toFixed(2)}`
|
|
541
|
+
: '-'
|
|
542
|
+
|
|
543
|
+
const actualRev = p.avgRetail != null ? p.actualUnits * p.avgRetail : 0
|
|
544
|
+
const projRev =
|
|
545
|
+
p.projectedAvgRetail != null
|
|
546
|
+
? p.projectedUnits * p.projectedAvgRetail
|
|
547
|
+
: 0
|
|
548
|
+
const revDelta = projRev - actualRev
|
|
549
|
+
const revDeltaStr =
|
|
550
|
+
actualRev > 0
|
|
551
|
+
? `${revDelta >= 0 ? '+' : ''}${fmtCompact(revDelta)}`
|
|
552
|
+
: '-'
|
|
553
|
+
|
|
554
|
+
lines.push(
|
|
555
|
+
`| ${p.clientName} | ${p.programCode} | ${fmtUnits(p.actualUnits)} | ${fmtUnits(p.projectedUnits)} | ${deltaStr} | ${retailStr} | ${projRetailStr} | ${revDeltaStr} |`,
|
|
556
|
+
)
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
lines.push(`\n${projections.length} programs`)
|
|
560
|
+
return lines.join('\n')
|
|
561
|
+
}
|