@viccydev/pi-fpa 0.9.4 → 0.9.6

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.
@@ -1,4 +1,4 @@
1
- export const DASHBOARD_WIDGET_TYPES = ["stat", "timeseries", "table", "grouped-table"] as const;
1
+ export const DASHBOARD_WIDGET_TYPES = ["stat", "timeseries", "table", "grouped-table", "waterfall", "stacked-bar"] as const;
2
2
  export type DashboardWidgetType = (typeof DASHBOARD_WIDGET_TYPES)[number];
3
3
 
4
4
  const TONES = new Set(["positive", "negative", "warning", "neutral"]);
@@ -106,10 +106,66 @@ function validateGroupedTable(source: Record<string, unknown>): void {
106
106
  }
107
107
  }
108
108
 
109
+ function validateWaterfall(source: Record<string, unknown>): void {
110
+ string(source.label, "dataset.label");
111
+ optionalString(source.description, "dataset.description");
112
+ optionalString(source.coverage, "dataset.coverage");
113
+ const steps = source.steps;
114
+ if (!Array.isArray(steps) || steps.length < 2 || steps.length > 32) {
115
+ throw new Error("dataset.steps must contain between 2 and 32 entries.");
116
+ }
117
+ for (const [index, raw] of steps.entries()) {
118
+ const step = record(raw, `dataset.steps[${index}]`);
119
+ string(step.label, `dataset.steps[${index}].label`);
120
+ if (step.value !== null && (typeof step.value !== "number" || !Number.isFinite(step.value))) {
121
+ throw new Error(`dataset.steps[${index}].value must be a finite number or null.`);
122
+ }
123
+ if (step.display !== null && typeof step.display !== "string") {
124
+ throw new Error(`dataset.steps[${index}].display must be a string or null.`);
125
+ }
126
+ if (step.kind !== undefined && step.kind !== "total" && step.kind !== "delta") {
127
+ throw new Error(`dataset.steps[${index}].kind must be "total" or "delta".`);
128
+ }
129
+ tone(step.tone, `dataset.steps[${index}].tone`);
130
+ }
131
+ }
132
+
133
+ function validateStackedBar(source: Record<string, unknown>): void {
134
+ string(source.label, "dataset.label");
135
+ optionalString(source.description, "dataset.description");
136
+ const bars = source.bars;
137
+ if (!Array.isArray(bars) || bars.length < 1 || bars.length > 8) {
138
+ throw new Error("dataset.bars must contain between 1 and 8 entries.");
139
+ }
140
+ for (const [barIndex, rawBar] of bars.entries()) {
141
+ const bar = record(rawBar, `dataset.bars[${barIndex}]`);
142
+ string(bar.label, `dataset.bars[${barIndex}].label`);
143
+ if (bar.total !== null && typeof bar.total !== "string") {
144
+ throw new Error(`dataset.bars[${barIndex}].total must be a string or null.`);
145
+ }
146
+ const segments = bar.segments;
147
+ if (!Array.isArray(segments) || segments.length < 1 || segments.length > 24) {
148
+ throw new Error(`dataset.bars[${barIndex}].segments must contain between 1 and 24 entries.`);
149
+ }
150
+ for (const [index, rawSegment] of segments.entries()) {
151
+ const segment = record(rawSegment, `dataset.bars[${barIndex}].segments[${index}]`);
152
+ string(segment.name, `dataset.bars[${barIndex}].segments[${index}].name`);
153
+ if (segment.value !== null && (typeof segment.value !== "number" || !Number.isFinite(segment.value))) {
154
+ throw new Error(`dataset.bars[${barIndex}].segments[${index}].value must be a finite number or null.`);
155
+ }
156
+ if (segment.display !== null && typeof segment.display !== "string") {
157
+ throw new Error(`dataset.bars[${barIndex}].segments[${index}].display must be a string or null.`);
158
+ }
159
+ }
160
+ }
161
+ }
162
+
109
163
  export function validateDatasetForWidget(type: DashboardWidgetType, value: unknown): void {
110
164
  const source = record(value, "dataset");
111
165
  if (type === "stat") validateStat(source);
112
166
  else if (type === "timeseries") validateTimeseries(source);
113
167
  else if (type === "table") validateTable(source);
168
+ else if (type === "waterfall") validateWaterfall(source);
169
+ else if (type === "stacked-bar") validateStackedBar(source);
114
170
  else validateGroupedTable(source);
115
171
  }
@@ -3,6 +3,7 @@ import { link, lstat, mkdir, open, readFile, realpath, unlink } from "node:fs/pr
3
3
  import { join } from "node:path";
4
4
 
5
5
  import { stableJson } from "../fpa-artifacts/store.ts";
6
+ import { createDecisionPackage } from "./decision-package.ts";
6
7
  import { resolveDashboardDir } from "./publisher.ts";
7
8
 
8
9
  const SHA256_RE = /^[a-f0-9]{64}$/;
@@ -30,6 +31,21 @@ export interface CommitStrategyDecisionInput {
30
31
  decision: "confirm" | "request_changes";
31
32
  feedback?: string;
32
33
  decidedAt?: string;
34
+ /**
35
+ * Context for the decision package materialised on `confirm`. Optional so a
36
+ * caller with no budget context still records the decision — the package
37
+ * then carries a null budget and says so rather than inventing one.
38
+ */
39
+ packageContext?: {
40
+ title?: string;
41
+ scopeId?: string;
42
+ cycleId?: string;
43
+ approverRole?: string;
44
+ owner?: string | null;
45
+ approvedBudgetUsd?: number | null;
46
+ budgetThresholdPct?: number;
47
+ parameters?: Record<string, unknown>;
48
+ };
33
49
  }
34
50
 
35
51
  export interface CommitStrategyDecisionResult {
@@ -38,6 +54,8 @@ export interface CommitStrategyDecisionResult {
38
54
  decision: "confirm" | "request_changes";
39
55
  strategyVersion: string;
40
56
  handoffFingerprint: string;
57
+ /** Set only on `confirm` — a rejected strategy has nothing to track. */
58
+ decisionId?: string;
41
59
  }
42
60
 
43
61
  export interface CommittedStrategyDecision {
@@ -173,9 +191,62 @@ export async function commitStrategyDecision(cwd: string, input: CommitStrategyD
173
191
  decision: existing.decision,
174
192
  strategyVersion: request.strategy_version,
175
193
  handoffFingerprint: request.handoff_fingerprint,
194
+ // The already-committed decision owns the timestamp. Re-deriving it
195
+ // here would change the package's content — and its id, across a UTC
196
+ // midnight — so a second submission would look like a different
197
+ // decision instead of the same one.
198
+ ...(existing.decision === "confirm"
199
+ ? { decisionId: await materialiseDecisionPackage(cwd, request, existing.decided_at, input.packageContext) }
200
+ : {}),
176
201
  };
177
202
  }
178
- return { decisionFingerprint, path, decision: input.decision, strategyVersion: request.strategy_version, handoffFingerprint: request.handoff_fingerprint };
203
+ return {
204
+ decisionFingerprint,
205
+ path,
206
+ decision: input.decision,
207
+ strategyVersion: request.strategy_version,
208
+ handoffFingerprint: request.handoff_fingerprint,
209
+ ...(input.decision === "confirm"
210
+ ? { decisionId: await materialiseDecisionPackage(cwd, request, decision.decided_at, input.packageContext) }
211
+ : {}),
212
+ };
213
+ }
214
+
215
+ /**
216
+ * Turn a confirmed strategy into a trackable object.
217
+ *
218
+ * Runs after the decision record is durable, so a package can never exist for
219
+ * a decision that was not committed. `createDecisionPackage` is idempotent on
220
+ * the same inputs, which is what makes the re-commit path above safe to call.
221
+ */
222
+ async function materialiseDecisionPackage(
223
+ cwd: string,
224
+ request: StrategyDecisionRequest,
225
+ decidedAt: string,
226
+ context: CommitStrategyDecisionInput["packageContext"],
227
+ ): Promise<string> {
228
+ const pkg = await createDecisionPackage(cwd, {
229
+ actionId: request.action_id,
230
+ strategyVersion: request.strategy_version,
231
+ handoffFingerprint: request.handoff_fingerprint,
232
+ title: context?.title ?? `执行策略 ${request.strategy_version}`,
233
+ scopeId: context?.scopeId ?? "unscoped",
234
+ cycleId: context?.cycleId ?? "uncycled",
235
+ approverRole: context?.approverRole ?? "CEO",
236
+ owner: context?.owner ?? null,
237
+ approvedBudgetUsd: context?.approvedBudgetUsd ?? null,
238
+ ...(context?.budgetThresholdPct !== undefined ? { budgetThresholdPct: context.budgetThresholdPct } : {}),
239
+ parameters: context?.parameters ?? {},
240
+ approvals: [{
241
+ step_order: 1,
242
+ role: context?.approverRole ?? "CEO",
243
+ approver: context?.approverRole ?? "CEO",
244
+ action: "approved",
245
+ decided_at: decidedAt,
246
+ }],
247
+ approvedAt: decidedAt,
248
+ });
249
+ return pkg.decision_id;
179
250
  }
180
251
 
181
252
  export async function readCommittedStrategyDecision(cwd: string, actionId: string, decisionFingerprint: string): Promise<CommittedStrategyDecision> {
@@ -0,0 +1,296 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { isAbsolute, join, resolve } from "node:path";
3
+
4
+ import { getDataset, type DatasetDef } from "./registry.ts";
5
+ import type { QuerySpec } from "./sql.ts";
6
+
7
+ // ============================================================================
8
+ // A CSV-backed executor for the same QuerySpec the SQL builder compiles.
9
+ //
10
+ // The registry, the query spec, the derived-metric engine, and the query
11
+ // receipt are all shared with the Supabase path — only the execution differs.
12
+ // That is the point: when the real finance import lands, this file is the only
13
+ // thing that gets replaced, and every metric definition and every projector
14
+ // keeps working untouched.
15
+ //
16
+ // The aggregation deliberately matches SQL, including the part people get
17
+ // wrong: SUM over a set where every contributing value is NULL is NULL, not 0.
18
+ // An FP&A dashboard that reports 0 for "we have no data" is worse than one
19
+ // that reports nothing.
20
+ // ============================================================================
21
+
22
+ const MAX_FILE_BYTES = 32 * 1024 * 1024;
23
+ const MAX_ROWS = 500_000;
24
+
25
+ export const CSV_ROOT_ENV = "FPA_FINANCE_CSV_ROOT";
26
+
27
+ /** Where a project's finance CSVs live, beside the projects like `.fpa-dashboard`. */
28
+ export const FINANCE_DIR_NAME = ".fpa-finance";
29
+
30
+ /**
31
+ * Resolve the finance CSV directory for one project.
32
+ *
33
+ * Defaults to the workspace-level `.fpa-finance/`, so a tenant reads only its
34
+ * own financials. The env override exists for a genuinely shared company
35
+ * source, and is explicit precisely because pointing every tenant at one
36
+ * directory is a data-leak shaped decision that should never be the default.
37
+ */
38
+ export function resolveCsvRoot(env: NodeJS.ProcessEnv = process.env, workspaceRoot?: string): string {
39
+ const configured = env[CSV_ROOT_ENV]?.trim();
40
+ if (configured) {
41
+ if (!isAbsolute(configured)) throw new Error(`${CSV_ROOT_ENV} must be an absolute path.`);
42
+ return resolve(configured);
43
+ }
44
+ if (workspaceRoot) return join(resolve(workspaceRoot), FINANCE_DIR_NAME);
45
+ throw new Error(
46
+ `Finance CSV datasets need either a project directory or ${CSV_ROOT_ENV} pointing at the directory holding them. ` +
47
+ "No query was run.",
48
+ );
49
+ }
50
+
51
+ /** Parse one CSV line, honouring quoted fields and doubled quotes. */
52
+ function parseLine(line: string): string[] {
53
+ const cells: string[] = [];
54
+ let current = "";
55
+ let quoted = false;
56
+ for (let index = 0; index < line.length; index += 1) {
57
+ const char = line[index];
58
+ if (quoted) {
59
+ if (char === '"' && line[index + 1] === '"') {
60
+ current += '"';
61
+ index += 1;
62
+ } else if (char === '"') {
63
+ quoted = false;
64
+ } else {
65
+ current += char;
66
+ }
67
+ } else if (char === '"') {
68
+ quoted = true;
69
+ } else if (char === ",") {
70
+ cells.push(current);
71
+ current = "";
72
+ } else {
73
+ current += char;
74
+ }
75
+ }
76
+ cells.push(current);
77
+ return cells;
78
+ }
79
+
80
+ export interface CsvTable {
81
+ headers: string[];
82
+ rows: Array<Record<string, string>>;
83
+ }
84
+
85
+ export async function readCsvTable(path: string): Promise<CsvTable> {
86
+ let raw: string;
87
+ try {
88
+ raw = await readFile(path, "utf8");
89
+ } catch (error) {
90
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
91
+ throw new Error(`Finance CSV file does not exist: ${path}`);
92
+ }
93
+ throw error;
94
+ }
95
+ if (Buffer.byteLength(raw, "utf8") > MAX_FILE_BYTES) {
96
+ throw new Error(`Finance CSV file exceeds the ${MAX_FILE_BYTES / (1024 * 1024)}MB limit: ${path}`);
97
+ }
98
+ const lines = raw.split("\n").filter((line, index) => index === 0 || line.trim() !== "");
99
+ if (lines.length === 0) throw new Error(`Finance CSV file is empty: ${path}`);
100
+ const headers = parseLine(lines[0]).map((header) => header.trim());
101
+ const rows: Array<Record<string, string>> = [];
102
+ for (const line of lines.slice(1)) {
103
+ if (rows.length >= MAX_ROWS) throw new Error(`Finance CSV file exceeds the ${MAX_ROWS}-row limit: ${path}`);
104
+ const cells = parseLine(line);
105
+ const row: Record<string, string> = {};
106
+ headers.forEach((header, index) => { row[header] = cells[index] ?? ""; });
107
+ rows.push(row);
108
+ }
109
+ return { headers, rows };
110
+ }
111
+
112
+ /** An empty cell is missing, not zero. */
113
+ function cellValue(raw: string | undefined): number | null {
114
+ if (raw === undefined || raw.trim() === "") return null;
115
+ const parsed = Number(raw);
116
+ return Number.isFinite(parsed) ? parsed : null;
117
+ }
118
+
119
+ /**
120
+ * The row's date, normalised so a YYYY-MM column compares against the
121
+ * YYYY-MM-DD bounds the query spec carries.
122
+ */
123
+ function rowDate(value: string | undefined, format: "month" | "date"): string | null {
124
+ if (!value || value.trim() === "") return null;
125
+ const text = value.trim();
126
+ return format === "month" ? `${text}-01` : text;
127
+ }
128
+
129
+ interface Accumulator {
130
+ sum: number;
131
+ count: number;
132
+ /** Null until a non-null value arrives, which is what keeps all-NULL null. */
133
+ seen: boolean;
134
+ min: number;
135
+ max: number;
136
+ last: number | null;
137
+ }
138
+
139
+ function aggregate(accumulator: Accumulator, fn: "sum" | "avg" | "min" | "max" | "last"): number | null {
140
+ if (!accumulator.seen) return null;
141
+ switch (fn) {
142
+ case "sum": return accumulator.sum;
143
+ case "avg": return accumulator.count === 0 ? null : accumulator.sum / accumulator.count;
144
+ case "min": return accumulator.min;
145
+ case "max": return accumulator.max;
146
+ case "last": return accumulator.last;
147
+ }
148
+ }
149
+
150
+ export interface CsvQueryResult {
151
+ rows: Array<Record<string, unknown>>;
152
+ dataset: DatasetDef;
153
+ measures: string[];
154
+ dimensions: string[];
155
+ notes: string[];
156
+ }
157
+
158
+ export async function runCsvQuery(
159
+ spec: QuerySpec,
160
+ env: NodeJS.ProcessEnv = process.env,
161
+ workspaceRoot?: string,
162
+ ): Promise<CsvQueryResult> {
163
+ const dataset = getDataset(spec.dataset);
164
+ if (dataset.source !== "csv" || !dataset.csv) throw new Error(`Dataset "${dataset.id}" is not CSV-backed.`);
165
+ for (const unsupported of ["exactUaScope", "exactUaPortfolioScope", "exactUaChannelScope"] as const) {
166
+ if (spec[unsupported] !== undefined) throw new Error(`${unsupported} is supported only for ua_spend.`);
167
+ }
168
+
169
+ const notes: string[] = [];
170
+ const dimensionNames = spec.dimensions ?? [];
171
+ const dimensionDefs = dimensionNames.map((name) => {
172
+ const def = dataset.dimensions.find((dimension) => dimension.name === name);
173
+ if (!def) {
174
+ throw new Error(
175
+ `Unknown dimension "${name}" for dataset "${dataset.id}". ` +
176
+ `Available: ${dataset.dimensions.map((dimension) => dimension.name).join(", ")}.`,
177
+ );
178
+ }
179
+ return def;
180
+ });
181
+
182
+ // Resolve metrics against the same registry rules the SQL path uses:
183
+ // a derived metric pulls in its numerator and denominator.
184
+ const baseNames = new Set(dataset.measures.map((measure) => measure.name));
185
+ const derivedByName = new Map(dataset.derived.map((derived) => [derived.name, derived]));
186
+ const selected = new Set<string>();
187
+ const derived: DatasetDef["derived"] = [];
188
+ for (const metric of spec.metrics) {
189
+ if (baseNames.has(metric)) {
190
+ selected.add(metric);
191
+ } else if (derivedByName.has(metric)) {
192
+ const def = derivedByName.get(metric)!;
193
+ derived.push(def);
194
+ selected.add(def.numerator);
195
+ selected.add(def.denominator);
196
+ } else {
197
+ throw new Error(
198
+ `Unknown metric "${metric}" for dataset "${dataset.id}". ` +
199
+ `Available metrics: ${[...baseNames, ...derivedByName.keys()].join(", ")}.`,
200
+ );
201
+ }
202
+ }
203
+ if (selected.size === 0) throw new Error("At least one metric is required.");
204
+
205
+ const table = await readCsvTable(join(resolveCsvRoot(env, workspaceRoot), dataset.csv.file));
206
+ const dateFormat = dataset.csv.dateFormat ?? "date";
207
+
208
+ const filters: Array<{ column: string; values: Set<string> }> = [];
209
+ for (const [key, raw] of Object.entries(spec.filters ?? {})) {
210
+ const def = dataset.dimensions.find((dimension) => dimension.name === key);
211
+ if (!def) {
212
+ throw new Error(
213
+ `Unknown filter field "${key}" for dataset "${dataset.id}". ` +
214
+ `Filterable fields: ${dataset.dimensions.map((dimension) => dimension.name).join(", ")}. ` +
215
+ "Use dateFrom/dateTo for the date range.",
216
+ );
217
+ }
218
+ const values = Array.isArray(raw) ? raw : [raw];
219
+ if (values.length === 0) throw new Error("Filter arrays must contain at least one value.");
220
+ filters.push({ column: def.sql, values: new Set(values.map(String)) });
221
+ }
222
+
223
+ const groups = new Map<string, { key: Record<string, string>; accumulators: Map<string, Accumulator>; sourceRows: number }>();
224
+ for (const row of table.rows) {
225
+ const date = rowDate(row[dataset.dateColumn], dateFormat);
226
+ if (spec.dateFrom && (date === null || date < spec.dateFrom)) continue;
227
+ if (spec.dateTo && (date === null || date > spec.dateTo)) continue;
228
+ if (filters.some((filter) => !filter.values.has(row[filter.column] ?? ""))) continue;
229
+
230
+ const key = Object.fromEntries(dimensionDefs.map((def) => [def.name, row[def.sql] ?? ""]));
231
+ const groupKey = dimensionDefs.map((def) => row[def.sql] ?? "").join("");
232
+ let group = groups.get(groupKey);
233
+ if (!group) {
234
+ group = { key, accumulators: new Map(), sourceRows: 0 };
235
+ groups.set(groupKey, group);
236
+ }
237
+ group.sourceRows += 1;
238
+ for (const measure of selected) {
239
+ const column = dataset.measures.find((entry) => entry.name === measure)!.sql;
240
+ const value = cellValue(row[column]);
241
+ let accumulator = group.accumulators.get(measure);
242
+ if (!accumulator) {
243
+ accumulator = { sum: 0, count: 0, seen: false, min: Infinity, max: -Infinity, last: null };
244
+ group.accumulators.set(measure, accumulator);
245
+ }
246
+ // A NULL contributes nothing and does not make the group non-null.
247
+ if (value === null) continue;
248
+ accumulator.seen = true;
249
+ accumulator.sum += value;
250
+ accumulator.count += 1;
251
+ accumulator.min = Math.min(accumulator.min, value);
252
+ accumulator.max = Math.max(accumulator.max, value);
253
+ accumulator.last = value;
254
+ }
255
+ }
256
+
257
+ let rows = [...groups.values()].map((group) => {
258
+ const out: Record<string, unknown> = { ...group.key, source_rows: group.sourceRows };
259
+ for (const measure of selected) {
260
+ const fn = dataset.csv!.aggregates[measure] ?? "sum";
261
+ const accumulator = group.accumulators.get(measure);
262
+ out[measure] = accumulator ? aggregate(accumulator, fn) : null;
263
+ }
264
+ return out;
265
+ });
266
+
267
+ if (spec.sort) {
268
+ const { by, direction = "desc" } = spec.sort;
269
+ if (!selected.has(by) && !dimensionNames.includes(by) && !derived.some((def) => def.name === by)) {
270
+ throw new Error(`Cannot sort by "${by}": it is not a selected metric or dimension.`);
271
+ }
272
+ rows.sort((left, right) => {
273
+ const a = left[by];
274
+ const b = right[by];
275
+ // Nulls sort last in both directions: a missing value is not the
276
+ // smallest value, it is an absent one.
277
+ if (a === null && b === null) return 0;
278
+ if (a === null) return 1;
279
+ if (b === null) return -1;
280
+ const comparison = typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b));
281
+ return direction === "asc" ? comparison : -comparison;
282
+ });
283
+ } else if (dimensionNames.length > 0) {
284
+ rows.sort((left, right) => dimensionNames
285
+ .map((name) => String(left[name] ?? "").localeCompare(String(right[name] ?? "")))
286
+ .find((value) => value !== 0) ?? 0);
287
+ }
288
+
289
+ const limit = spec.limit ?? 200;
290
+ if (rows.length > limit) {
291
+ notes.push(`Returned the first ${limit} of ${rows.length} groups.`);
292
+ rows = rows.slice(0, limit);
293
+ }
294
+
295
+ return { rows, dataset, measures: [...selected], dimensions: dimensionNames, notes };
296
+ }