@viccydev/pi-fpa 0.9.5 → 0.9.7
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 +2 -2
- package/bin/fpa-dashboard-worker.mjs +6 -1
- package/extensions/fpa-dashboard/calibration-projector.ts +359 -0
- package/extensions/fpa-dashboard/compat-publisher.ts +89 -3
- package/extensions/fpa-dashboard/finance-projector.ts +375 -0
- package/extensions/fpa-dashboard/index.ts +119 -0
- package/extensions/fpa-dashboard/projector.ts +26 -1
- package/extensions/fpa-dashboard/schema.ts +57 -1
- package/extensions/fpa-data/csvsource.ts +296 -0
- package/extensions/fpa-data/registry.ts +324 -0
- package/extensions/fpa-data/runtime.ts +21 -1
- package/package.json +3 -3
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import { realpath } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { runStructuredQuery } from "../fpa-data/runtime.ts";
|
|
5
|
+
import type { DashboardModuleBuild } from "./module-publisher.ts";
|
|
6
|
+
import type { DashboardWidget, TableCell, WaterfallDataset } from "./projector.ts";
|
|
7
|
+
|
|
8
|
+
// ============================================================================
|
|
9
|
+
// The portfolio overview module.
|
|
10
|
+
//
|
|
11
|
+
// Answers "how is the business doing" from the finance datasets, which the UA
|
|
12
|
+
// mart cannot reach: cash, cost structure, unit economics.
|
|
13
|
+
//
|
|
14
|
+
// Three constraints from the data contract are load-bearing here, and every
|
|
15
|
+
// one of them exists because breaking it produces a number that looks right
|
|
16
|
+
// and is wrong:
|
|
17
|
+
//
|
|
18
|
+
// * Opex is a company-level cost. Per-app figures stop at contribution
|
|
19
|
+
// margin; only the portfolio has FCF. Allocating opex across apps would
|
|
20
|
+
// manufacture precision nobody measured.
|
|
21
|
+
//
|
|
22
|
+
// * Runway is cash over fixed outflow, not over net burn. A burn-based
|
|
23
|
+
// runway is null or infinite while FCF is positive, which tells a reader
|
|
24
|
+
// nothing on the one screen where they came to ask the question.
|
|
25
|
+
//
|
|
26
|
+
// * A month whose source rows were incomplete is reported as incomplete.
|
|
27
|
+
// Its total is understated, and presenting it as a clean aggregate hides
|
|
28
|
+
// that.
|
|
29
|
+
// ============================================================================
|
|
30
|
+
|
|
31
|
+
const MONEY_FIELDS = [
|
|
32
|
+
"gross_billings", "refunds", "platform_fee", "net_proceeds",
|
|
33
|
+
"ua_spend", "contribution_margin", "tax", "opex_total", "net_fcf",
|
|
34
|
+
] as const;
|
|
35
|
+
|
|
36
|
+
export interface FinanceProjectionOptions {
|
|
37
|
+
/** Inclusive month bounds, YYYY-MM. Defaults to the last 13 months present. */
|
|
38
|
+
months?: number;
|
|
39
|
+
locale?: string;
|
|
40
|
+
signal?: AbortSignal;
|
|
41
|
+
/**
|
|
42
|
+
* The project directory. Its workspace owns the finance CSVs, so one
|
|
43
|
+
* tenant can never project another tenant's financials.
|
|
44
|
+
*/
|
|
45
|
+
cwd?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface FinanceProjection {
|
|
49
|
+
module: DashboardModuleBuild;
|
|
50
|
+
/** The latest closed month the projection reports on. */
|
|
51
|
+
closedMonth: string | null;
|
|
52
|
+
/** The open month, when the source carries one. */
|
|
53
|
+
openMonth: string | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function currency(value: number | null, locale: string, maximumFractionDigits = 0): string | null {
|
|
57
|
+
if (value === null) return null;
|
|
58
|
+
return new Intl.NumberFormat(locale, { style: "currency", currency: "USD", maximumFractionDigits }).format(value);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function percent(value: number | null, locale: string): string | null {
|
|
62
|
+
if (value === null) return null;
|
|
63
|
+
return new Intl.NumberFormat(locale, { style: "percent", maximumFractionDigits: 1 }).format(value);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function ratio(value: number | null, locale: string): string | null {
|
|
67
|
+
if (value === null) return null;
|
|
68
|
+
return `${new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format(value)}x`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function toned(value: string | null, tone?: "positive" | "negative" | "warning" | "neutral"): TableCell {
|
|
72
|
+
return tone ? { value, tone } : value;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function num(row: Record<string, unknown> | undefined, key: string): number | null {
|
|
76
|
+
const value = row?.[key];
|
|
77
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function text(row: Record<string, unknown> | undefined, key: string): string | null {
|
|
81
|
+
const value = row?.[key];
|
|
82
|
+
return typeof value === "string" && value !== "" ? value : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The P&L bridge for one month.
|
|
87
|
+
*
|
|
88
|
+
* Deductions are emitted as negative deltas so the chart never has to infer a
|
|
89
|
+
* sign, and the two anchors are marked `total` so they are drawn from zero
|
|
90
|
+
* rather than floating on the step before them.
|
|
91
|
+
*/
|
|
92
|
+
function pnlWaterfall(row: Record<string, unknown>, month: string, locale: string): WaterfallDataset {
|
|
93
|
+
const step = (key: string, label: string, negate: boolean, kind: "total" | "delta", tone?: "positive" | "negative" | "warning" | "neutral") => {
|
|
94
|
+
const raw = num(row, key);
|
|
95
|
+
const value = raw === null ? null : negate ? -raw : raw;
|
|
96
|
+
return { label, value, display: currency(value, locale), kind, ...(tone ? { tone } : {}) };
|
|
97
|
+
};
|
|
98
|
+
return {
|
|
99
|
+
label: "利润桥",
|
|
100
|
+
description: "从流水到净自由现金流,逐项拆解",
|
|
101
|
+
coverage: month,
|
|
102
|
+
steps: [
|
|
103
|
+
step("gross_billings", "流水", false, "total"),
|
|
104
|
+
step("refunds", "退款", true, "delta"),
|
|
105
|
+
step("platform_fee", "平台分成", true, "delta"),
|
|
106
|
+
step("tax", "税费", true, "delta"),
|
|
107
|
+
step("ua_spend", "广告投放", true, "delta"),
|
|
108
|
+
step("opex_total", "运营成本", true, "delta"),
|
|
109
|
+
step("net_fcf", "净自由现金流", false, "total", "positive"),
|
|
110
|
+
],
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function projectPortfolioOverview(options: FinanceProjectionOptions = {}): Promise<FinanceProjection> {
|
|
115
|
+
const locale = options.locale ?? "zh-CN";
|
|
116
|
+
const workspaceRoot = options.cwd ? dirname(await realpath(options.cwd)) : undefined;
|
|
117
|
+
const limit = options.months ?? 24;
|
|
118
|
+
const warnings: string[] = [];
|
|
119
|
+
|
|
120
|
+
const pnl = await runStructuredQuery({
|
|
121
|
+
dataset: "pnl_monthly",
|
|
122
|
+
metrics: [...MONEY_FIELDS, "fcf_margin", "roas_blended"],
|
|
123
|
+
dimensions: ["month", "close_status", "data_completeness"],
|
|
124
|
+
limit,
|
|
125
|
+
}, options.signal, workspaceRoot);
|
|
126
|
+
const months = pnl.rows.slice().sort((left, right) => String(left.month).localeCompare(String(right.month)));
|
|
127
|
+
if (months.length === 0) throw new Error("The finance source contains no P&L months; nothing can be projected.");
|
|
128
|
+
|
|
129
|
+
const closedRows = months.filter((row) => text(row, "close_status") === "closed");
|
|
130
|
+
const openRow = months.find((row) => text(row, "close_status") === "open");
|
|
131
|
+
const latestClosed = closedRows.at(-1);
|
|
132
|
+
const closedMonth = latestClosed ? String(latestClosed.month) : null;
|
|
133
|
+
const priorClosed = closedRows.at(-2);
|
|
134
|
+
|
|
135
|
+
// An incomplete month is named, not silently totalled.
|
|
136
|
+
for (const row of months) {
|
|
137
|
+
if (text(row, "data_completeness") === "partial") {
|
|
138
|
+
warnings.push(`${String(row.month)} 的组合合计不完整:有来源明细缺失,该月总额被低估。`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (openRow) {
|
|
142
|
+
warnings.push(`${String(openRow.month)} 尚未结账,其税费、运营成本与净自由现金流显示为缺失而非零。`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const cash = await runStructuredQuery({
|
|
146
|
+
dataset: "cash_position_monthly",
|
|
147
|
+
metrics: ["closing_cash", "runway_months", "monthly_fixed_outflow"],
|
|
148
|
+
dimensions: ["month"],
|
|
149
|
+
filters: { close_status: "closed" },
|
|
150
|
+
limit,
|
|
151
|
+
}, options.signal, workspaceRoot);
|
|
152
|
+
const latestCash = cash.rows.slice().sort((left, right) => String(left.month).localeCompare(String(right.month))).at(-1);
|
|
153
|
+
|
|
154
|
+
const byApp = await runStructuredQuery({
|
|
155
|
+
dataset: "pnl_by_app_monthly",
|
|
156
|
+
metrics: ["net_proceeds", "ua_spend", "contribution_margin", "app_roas"],
|
|
157
|
+
dimensions: ["app_id", "app_name", "store"],
|
|
158
|
+
...(closedMonth ? { filters: { month: closedMonth } } : {}),
|
|
159
|
+
sort: { by: "contribution_margin", direction: "desc" },
|
|
160
|
+
limit: 100,
|
|
161
|
+
}, options.signal, workspaceRoot);
|
|
162
|
+
|
|
163
|
+
const opex = await runStructuredQuery({
|
|
164
|
+
dataset: "opex_monthly",
|
|
165
|
+
metrics: ["amount_usd"],
|
|
166
|
+
dimensions: ["category"],
|
|
167
|
+
...(closedMonth ? { filters: { month: closedMonth, close_status: "closed" } } : {}),
|
|
168
|
+
limit: 20,
|
|
169
|
+
}, options.signal, workspaceRoot);
|
|
170
|
+
|
|
171
|
+
const cohorts = await runStructuredQuery({
|
|
172
|
+
dataset: "cohort_ltv",
|
|
173
|
+
metrics: ["installs", "ua_spend", "cac", "ltv_180", "roas_d180", "ltv_to_cac"],
|
|
174
|
+
dimensions: ["store", "maturity"],
|
|
175
|
+
filters: { maturity: "mature" },
|
|
176
|
+
limit: 20,
|
|
177
|
+
}, options.signal, workspaceRoot);
|
|
178
|
+
if (cohorts.rows.length === 0) {
|
|
179
|
+
warnings.push("尚无成熟 cohort,LTV 与回收期显示为缺失。");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const fcf = num(latestClosed, "net_fcf");
|
|
183
|
+
const priorFcf = num(priorClosed, "net_fcf");
|
|
184
|
+
const fcfDelta = fcf !== null && priorFcf !== null && priorFcf !== 0 ? fcf / priorFcf - 1 : null;
|
|
185
|
+
|
|
186
|
+
const widgets: DashboardWidget[] = [
|
|
187
|
+
{
|
|
188
|
+
id: "portfolio-fcf",
|
|
189
|
+
type: "stat",
|
|
190
|
+
span: "quarter",
|
|
191
|
+
dataset: "portfolio-fcf.json",
|
|
192
|
+
data: {
|
|
193
|
+
label: "净自由现金流",
|
|
194
|
+
value: currency(fcf, locale),
|
|
195
|
+
...(fcf === null ? { missingReason: "最近月份尚未结账。" } : {}),
|
|
196
|
+
...(fcfDelta !== null
|
|
197
|
+
? { delta: { direction: fcfDelta >= 0 ? "up" as const : "down" as const, label: `${fcfDelta > 0 ? "+" : ""}${percent(fcfDelta, locale)}`, sentiment: fcfDelta >= 0 ? "positive" as const : "negative" as const } }
|
|
198
|
+
: {}),
|
|
199
|
+
...(closedMonth ? { footnote: `${closedMonth} 已结账` } : {}),
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
id: "portfolio-runway",
|
|
204
|
+
type: "stat",
|
|
205
|
+
span: "quarter",
|
|
206
|
+
dataset: "portfolio-runway.json",
|
|
207
|
+
data: {
|
|
208
|
+
label: "现金 Runway",
|
|
209
|
+
value: num(latestCash, "runway_months") === null ? null : `${num(latestCash, "runway_months")!.toFixed(1)} 月`,
|
|
210
|
+
...(num(latestCash, "runway_months") === null ? { missingReason: "尚无已结账的现金头寸。" } : {}),
|
|
211
|
+
description: `期末现金 ${currency(num(latestCash, "closing_cash"), locale) ?? "—"}`,
|
|
212
|
+
// Naming the definition on the card stops it being read as
|
|
213
|
+
// burn-based runway, which would be a much shorter number.
|
|
214
|
+
footnote: "口径:期末现金 ÷ 月固定支出(运营成本 + 投放)",
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
id: "portfolio-contribution",
|
|
219
|
+
type: "stat",
|
|
220
|
+
span: "quarter",
|
|
221
|
+
dataset: "portfolio-contribution.json",
|
|
222
|
+
data: {
|
|
223
|
+
label: "贡献毛利",
|
|
224
|
+
value: currency(num(latestClosed, "contribution_margin"), locale),
|
|
225
|
+
description: "净收入 − 广告投放",
|
|
226
|
+
footnote: "运营成本为公司级,不摊到 App",
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
id: "portfolio-fcf-margin",
|
|
231
|
+
type: "stat",
|
|
232
|
+
span: "quarter",
|
|
233
|
+
dataset: "portfolio-fcf-margin.json",
|
|
234
|
+
data: {
|
|
235
|
+
label: "现金流利润率",
|
|
236
|
+
value: percent(num(latestClosed, "fcf_margin"), locale),
|
|
237
|
+
...(num(latestClosed, "fcf_margin") === null ? { missingReason: "该月尚未结账。" } : {}),
|
|
238
|
+
...(num(latestClosed, "fcf_margin") !== null
|
|
239
|
+
? {
|
|
240
|
+
progress: {
|
|
241
|
+
fraction: Math.max(0, Math.min(1, num(latestClosed, "fcf_margin")!)),
|
|
242
|
+
label: percent(num(latestClosed, "fcf_margin"), locale) ?? "—",
|
|
243
|
+
tone: (num(latestClosed, "fcf_margin")! >= 0.3 ? "positive" : "warning") as "positive" | "warning",
|
|
244
|
+
},
|
|
245
|
+
}
|
|
246
|
+
: {}),
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
];
|
|
250
|
+
|
|
251
|
+
if (latestClosed) {
|
|
252
|
+
widgets.push({
|
|
253
|
+
id: "portfolio-pnl-bridge",
|
|
254
|
+
type: "waterfall",
|
|
255
|
+
span: "full",
|
|
256
|
+
dataset: "portfolio-pnl-bridge.json",
|
|
257
|
+
data: pnlWaterfall(latestClosed, closedMonth ?? "", locale),
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
widgets.push({
|
|
262
|
+
id: "portfolio-fcf-trend",
|
|
263
|
+
type: "timeseries",
|
|
264
|
+
span: "full",
|
|
265
|
+
dataset: "portfolio-fcf-trend.json",
|
|
266
|
+
data: {
|
|
267
|
+
label: "净收入与现金流走势",
|
|
268
|
+
description: "按月,未结账月份留空",
|
|
269
|
+
coverage: `${String(months[0].month)} → ${String(months.at(-1)!.month)}`,
|
|
270
|
+
series: [
|
|
271
|
+
{ name: "净收入", points: months.map((row) => ({ x: String(row.month), y: num(row, "net_proceeds") })) },
|
|
272
|
+
{ name: "净自由现金流", points: months.map((row) => ({ x: String(row.month), y: num(row, "net_fcf") })) },
|
|
273
|
+
],
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
if (opex.rows.length > 0) {
|
|
278
|
+
widgets.push({
|
|
279
|
+
id: "portfolio-cost-structure",
|
|
280
|
+
type: "stacked-bar",
|
|
281
|
+
span: "half",
|
|
282
|
+
dataset: "portfolio-cost-structure.json",
|
|
283
|
+
data: {
|
|
284
|
+
label: "成本结构",
|
|
285
|
+
description: closedMonth ? `${closedMonth} 运营成本构成` : "运营成本构成",
|
|
286
|
+
bars: [{
|
|
287
|
+
label: closedMonth ?? "最近已结账月",
|
|
288
|
+
total: currency(num(latestClosed, "opex_total"), locale),
|
|
289
|
+
segments: opex.rows.map((row) => ({
|
|
290
|
+
name: String(row.category),
|
|
291
|
+
value: num(row, "amount_usd"),
|
|
292
|
+
display: currency(num(row, "amount_usd"), locale),
|
|
293
|
+
})),
|
|
294
|
+
}],
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
widgets.push({
|
|
300
|
+
id: "portfolio-unit-economics",
|
|
301
|
+
type: "table",
|
|
302
|
+
span: "half",
|
|
303
|
+
dataset: "portfolio-unit-economics.json",
|
|
304
|
+
data: {
|
|
305
|
+
label: "单位经济(成熟 cohort)",
|
|
306
|
+
description: "仅统计满 180 天的 cohort;未成熟的显示为缺失",
|
|
307
|
+
columns: [
|
|
308
|
+
{ key: "store", label: "商店" },
|
|
309
|
+
{ key: "installs", label: "安装", align: "right" },
|
|
310
|
+
{ key: "cac", label: "CAC", align: "right" },
|
|
311
|
+
{ key: "ltv", label: "LTV 180", align: "right" },
|
|
312
|
+
{ key: "ltv_cac", label: "LTV/CAC", align: "right" },
|
|
313
|
+
{ key: "roas", label: "ROAS 180", align: "right" },
|
|
314
|
+
],
|
|
315
|
+
rows: cohorts.rows.map((row) => ({
|
|
316
|
+
store: String(row.store),
|
|
317
|
+
installs: num(row, "installs") === null ? null : new Intl.NumberFormat(locale).format(num(row, "installs")!),
|
|
318
|
+
cac: currency(num(row, "cac"), locale, 2),
|
|
319
|
+
ltv: currency(num(row, "ltv_180"), locale, 2),
|
|
320
|
+
ltv_cac: toned(ratio(num(row, "ltv_to_cac"), locale), num(row, "ltv_to_cac") === null ? undefined : num(row, "ltv_to_cac")! >= 1.5 ? "positive" : "warning"),
|
|
321
|
+
roas: ratio(num(row, "roas_d180"), locale),
|
|
322
|
+
})),
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
widgets.push({
|
|
327
|
+
id: "portfolio-by-app",
|
|
328
|
+
type: "table",
|
|
329
|
+
span: "full",
|
|
330
|
+
dataset: "portfolio-by-app.json",
|
|
331
|
+
data: {
|
|
332
|
+
label: "App 贡献",
|
|
333
|
+
description: closedMonth ? `${closedMonth},按贡献毛利排序。运营成本为公司级,故此处不含 FCF。` : "按贡献毛利排序",
|
|
334
|
+
columns: [
|
|
335
|
+
{ key: "app", label: "App" },
|
|
336
|
+
{ key: "store", label: "商店" },
|
|
337
|
+
{ key: "net_proceeds", label: "净收入", align: "right" },
|
|
338
|
+
{ key: "ua_spend", label: "投放", align: "right" },
|
|
339
|
+
{ key: "contribution", label: "贡献毛利", align: "right" },
|
|
340
|
+
{ key: "roas", label: "ROAS", align: "right" },
|
|
341
|
+
],
|
|
342
|
+
rows: byApp.rows.map((row) => ({
|
|
343
|
+
app: `${text(row, "app_name") ?? String(row.app_id)}(${String(row.app_id)})`,
|
|
344
|
+
store: String(row.store),
|
|
345
|
+
net_proceeds: currency(num(row, "net_proceeds"), locale),
|
|
346
|
+
ua_spend: currency(num(row, "ua_spend"), locale),
|
|
347
|
+
contribution: toned(
|
|
348
|
+
currency(num(row, "contribution_margin"), locale),
|
|
349
|
+
num(row, "contribution_margin") === null ? undefined : num(row, "contribution_margin")! < 0 ? "negative" : "positive",
|
|
350
|
+
),
|
|
351
|
+
roas: ratio(num(row, "app_roas"), locale),
|
|
352
|
+
})),
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
module: {
|
|
358
|
+
id: "portfolio-overview",
|
|
359
|
+
title: "组合总览",
|
|
360
|
+
status: "published",
|
|
361
|
+
source: {
|
|
362
|
+
artifact_type: "finance_csv",
|
|
363
|
+
closed_month: closedMonth,
|
|
364
|
+
open_month: openRow ? String(openRow.month) : null,
|
|
365
|
+
month_count: months.length,
|
|
366
|
+
datasets: ["pnl_monthly", "pnl_by_app_monthly", "opex_monthly", "cash_position_monthly", "cohort_ltv"],
|
|
367
|
+
data_as_of: closedMonth,
|
|
368
|
+
},
|
|
369
|
+
warnings,
|
|
370
|
+
widgets,
|
|
371
|
+
},
|
|
372
|
+
closedMonth,
|
|
373
|
+
openMonth: openRow ? String(openRow.month) : null,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
} from "./module-publisher.ts";
|
|
15
15
|
import { projectForecastModule, projectReviewModule, projectStrategyModule } from "./stage-projector.ts";
|
|
16
16
|
import { projectDecisionLedger } from "./decision-ledger-projector.ts";
|
|
17
|
+
import { projectPortfolioOverview } from "./finance-projector.ts";
|
|
18
|
+
import { projectCalibration } from "./calibration-projector.ts";
|
|
17
19
|
import { listDecisionPackages, readDecisionEvents } from "./decision-package.ts";
|
|
18
20
|
import type { ApprovedCycleForecast } from "../fpa-artifacts/contracts.ts";
|
|
19
21
|
import { commitStrategyDecision, createStrategyDecisionRequest, readCommittedStrategyDecision } from "./strategy-decision.ts";
|
|
@@ -329,6 +331,123 @@ export default function fpaDashboardExtension(pi: ExtensionAPI): void {
|
|
|
329
331
|
},
|
|
330
332
|
});
|
|
331
333
|
|
|
334
|
+
pi.registerTool({
|
|
335
|
+
name: "fpa_dashboard_publish_portfolio",
|
|
336
|
+
label: "Publish FP&A Portfolio Overview Module",
|
|
337
|
+
description: "Preview or publish only the portfolio-overview module from the finance datasets (P&L, cash, opex, cohorts). Other dashboard modules are preserved.",
|
|
338
|
+
promptSnippet: "Publish the portfolio financial overview",
|
|
339
|
+
promptGuidelines: [
|
|
340
|
+
"This tool is called by the main Agent; never add it to a Graph tool allowlist.",
|
|
341
|
+
"Preview first, then publish with the exact preview fingerprint and dashboard revision.",
|
|
342
|
+
"Requires FPA_FINANCE_CSV_ROOT to point at the finance data directory.",
|
|
343
|
+
"Report the module's warnings to the user: an unclosed month and an incomplete total are limits on the decision, not formatting details.",
|
|
344
|
+
],
|
|
345
|
+
parameters: Type.Object({
|
|
346
|
+
mode: StringEnum(["preview", "publish"] as const),
|
|
347
|
+
expected_preview_fingerprint: Type.Optional(Type.String({ pattern: "^[a-f0-9]{64}$" })),
|
|
348
|
+
expected_dashboard_revision: Type.Optional(Type.Union([Type.String({ pattern: "^[a-f0-9]{64}$" }), Type.Null()])),
|
|
349
|
+
dashboard_title: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })),
|
|
350
|
+
months: Type.Optional(Type.Integer({ minimum: 2, maximum: 120 })),
|
|
351
|
+
}, { additionalProperties: false }),
|
|
352
|
+
executionMode: "sequential",
|
|
353
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
354
|
+
const projection = await projectPortfolioOverview({
|
|
355
|
+
cwd: ctx.cwd,
|
|
356
|
+
...(params.months !== undefined ? { months: params.months } : {}),
|
|
357
|
+
...(signal ? { signal } : {}),
|
|
358
|
+
});
|
|
359
|
+
const previewFingerprint = dashboardModuleBuildFingerprint(projection.module);
|
|
360
|
+
const current = await readDashboardModuleManifest(ctx.cwd);
|
|
361
|
+
if (params.mode === "preview") return toolResult({
|
|
362
|
+
status: "ready",
|
|
363
|
+
module_id: projection.module.id,
|
|
364
|
+
preview_fingerprint: previewFingerprint,
|
|
365
|
+
dashboard_revision: current?.dashboardRevision ?? null,
|
|
366
|
+
widget_count: projection.module.widgets.length,
|
|
367
|
+
closed_month: projection.closedMonth,
|
|
368
|
+
open_month: projection.openMonth,
|
|
369
|
+
warnings: projection.module.warnings ?? [],
|
|
370
|
+
});
|
|
371
|
+
if (params.expected_preview_fingerprint !== previewFingerprint) throw new Error("Portfolio inputs changed after preview; preview again before publishing.");
|
|
372
|
+
if (params.expected_dashboard_revision === undefined) throw new Error("Publish requires expected_dashboard_revision from preview, including null for a new dashboard.");
|
|
373
|
+
const published = await publishDashboardModule({
|
|
374
|
+
cwd: ctx.cwd,
|
|
375
|
+
module: projection.module,
|
|
376
|
+
dashboardTitle: params.dashboard_title,
|
|
377
|
+
expectedDashboardRevision: params.expected_dashboard_revision,
|
|
378
|
+
});
|
|
379
|
+
return toolResult({
|
|
380
|
+
status: "published",
|
|
381
|
+
module_id: projection.module.id,
|
|
382
|
+
preview_fingerprint: previewFingerprint,
|
|
383
|
+
dashboard_revision: published.dashboardRevision,
|
|
384
|
+
module_revision: published.moduleRevision,
|
|
385
|
+
warnings: projection.module.warnings ?? [],
|
|
386
|
+
});
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
pi.registerTool({
|
|
391
|
+
name: "fpa_dashboard_publish_calibration",
|
|
392
|
+
label: "Publish FP&A Calibration Module",
|
|
393
|
+
description: "Preview or publish only the forecast-accuracy module: model error, bias direction, variance attribution, and the baseline corrections detected against it. Other dashboard modules are preserved.",
|
|
394
|
+
promptSnippet: "Publish forecast accuracy and the baseline corrections it implies",
|
|
395
|
+
promptGuidelines: [
|
|
396
|
+
"This tool is called by the main Agent; never add it to a Graph tool allowlist.",
|
|
397
|
+
"Preview first, then publish with the exact preview fingerprint and dashboard revision.",
|
|
398
|
+
"A suggested baseline is a proposal awaiting its approver. Never apply one to a forecast before it is approved, and never describe a pending correction as if it were in force.",
|
|
399
|
+
"Report the bias run and the attribution coverage to the user: a partly-explained variance is a limit on the conclusion, not a rounding detail.",
|
|
400
|
+
],
|
|
401
|
+
parameters: Type.Object({
|
|
402
|
+
mode: StringEnum(["preview", "publish"] as const),
|
|
403
|
+
metric: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
|
|
404
|
+
horizon_months: Type.Optional(Type.Integer({ minimum: 1, maximum: 24 })),
|
|
405
|
+
month: Type.Optional(Type.String({ pattern: "^\\d{4}-\\d{2}$" })),
|
|
406
|
+
expected_preview_fingerprint: Type.Optional(Type.String({ pattern: "^[a-f0-9]{64}$" })),
|
|
407
|
+
expected_dashboard_revision: Type.Optional(Type.Union([Type.String({ pattern: "^[a-f0-9]{64}$" }), Type.Null()])),
|
|
408
|
+
dashboard_title: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })),
|
|
409
|
+
}, { additionalProperties: false }),
|
|
410
|
+
executionMode: "sequential",
|
|
411
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
412
|
+
const projection = await projectCalibration({
|
|
413
|
+
cwd: ctx.cwd,
|
|
414
|
+
...(params.metric !== undefined ? { metric: params.metric } : {}),
|
|
415
|
+
...(params.horizon_months !== undefined ? { horizonMonths: params.horizon_months } : {}),
|
|
416
|
+
...(params.month !== undefined ? { month: params.month } : {}),
|
|
417
|
+
...(signal ? { signal } : {}),
|
|
418
|
+
});
|
|
419
|
+
const previewFingerprint = dashboardModuleBuildFingerprint(projection.module);
|
|
420
|
+
const current = await readDashboardModuleManifest(ctx.cwd);
|
|
421
|
+
if (params.mode === "preview") return toolResult({
|
|
422
|
+
status: "ready",
|
|
423
|
+
module_id: projection.module.id,
|
|
424
|
+
preview_fingerprint: previewFingerprint,
|
|
425
|
+
dashboard_revision: current?.dashboardRevision ?? null,
|
|
426
|
+
widget_count: projection.module.widgets.length,
|
|
427
|
+
mape: projection.mape,
|
|
428
|
+
bias_run: projection.biasRun,
|
|
429
|
+
pending_corrections: projection.pendingCorrections,
|
|
430
|
+
warnings: projection.module.warnings ?? [],
|
|
431
|
+
});
|
|
432
|
+
if (params.expected_preview_fingerprint !== previewFingerprint) throw new Error("Calibration inputs changed after preview; preview again before publishing.");
|
|
433
|
+
if (params.expected_dashboard_revision === undefined) throw new Error("Publish requires expected_dashboard_revision from preview, including null for a new dashboard.");
|
|
434
|
+
const published = await publishDashboardModule({
|
|
435
|
+
cwd: ctx.cwd,
|
|
436
|
+
module: projection.module,
|
|
437
|
+
dashboardTitle: params.dashboard_title,
|
|
438
|
+
expectedDashboardRevision: params.expected_dashboard_revision,
|
|
439
|
+
});
|
|
440
|
+
return toolResult({
|
|
441
|
+
status: "published",
|
|
442
|
+
module_id: projection.module.id,
|
|
443
|
+
preview_fingerprint: previewFingerprint,
|
|
444
|
+
dashboard_revision: published.dashboardRevision,
|
|
445
|
+
module_revision: published.moduleRevision,
|
|
446
|
+
warnings: projection.module.warnings ?? [],
|
|
447
|
+
});
|
|
448
|
+
},
|
|
449
|
+
});
|
|
450
|
+
|
|
332
451
|
pi.registerTool({
|
|
333
452
|
name: "fpa_dashboard_status",
|
|
334
453
|
label: "FP&A Dashboard Status",
|
|
@@ -52,11 +52,36 @@ export interface GroupedTableDataset {
|
|
|
52
52
|
}>;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
export interface WaterfallDataset {
|
|
56
|
+
label: string;
|
|
57
|
+
description?: string;
|
|
58
|
+
coverage?: string;
|
|
59
|
+
steps: Array<{
|
|
60
|
+
label: string;
|
|
61
|
+
value: number | null;
|
|
62
|
+
display: string | null;
|
|
63
|
+
kind?: "total" | "delta";
|
|
64
|
+
tone?: "positive" | "negative" | "warning" | "neutral";
|
|
65
|
+
}>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface StackedBarDataset {
|
|
69
|
+
label: string;
|
|
70
|
+
description?: string;
|
|
71
|
+
bars: Array<{
|
|
72
|
+
label: string;
|
|
73
|
+
total: string | null;
|
|
74
|
+
segments: Array<{ name: string; value: number | null; display: string | null }>;
|
|
75
|
+
}>;
|
|
76
|
+
}
|
|
77
|
+
|
|
55
78
|
export type DashboardWidget =
|
|
56
79
|
| { id: string; type: "stat"; span: "quarter" | "half" | "full"; dataset: string; data: StatDataset }
|
|
57
80
|
| { id: string; type: "timeseries"; span: "quarter" | "half" | "full"; dataset: string; data: TimeseriesDataset }
|
|
58
81
|
| { id: string; type: "table"; span: "quarter" | "half" | "full"; dataset: string; data: TableDataset }
|
|
59
|
-
| { id: string; type: "grouped-table"; span: "quarter" | "half" | "full"; dataset: string; data: GroupedTableDataset }
|
|
82
|
+
| { id: string; type: "grouped-table"; span: "quarter" | "half" | "full"; dataset: string; data: GroupedTableDataset }
|
|
83
|
+
| { id: string; type: "waterfall"; span: "quarter" | "half" | "full"; dataset: string; data: WaterfallDataset }
|
|
84
|
+
| { id: string; type: "stacked-bar"; span: "quarter" | "half" | "full"; dataset: string; data: StackedBarDataset };
|
|
60
85
|
|
|
61
86
|
export interface DashboardBuild {
|
|
62
87
|
title: string;
|
|
@@ -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
|
}
|