@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.
- package/README.md +2 -2
- package/extensions/fpa-dashboard/calibration-projector.ts +359 -0
- package/extensions/fpa-dashboard/compat-publisher.ts +89 -3
- package/extensions/fpa-dashboard/decision-ledger-projector.ts +323 -0
- package/extensions/fpa-dashboard/decision-package.ts +437 -0
- package/extensions/fpa-dashboard/finance-projector.ts +375 -0
- package/extensions/fpa-dashboard/index.ts +186 -0
- package/extensions/fpa-dashboard/projector.ts +26 -1
- package/extensions/fpa-dashboard/schema.ts +57 -1
- package/extensions/fpa-dashboard/strategy-decision.ts +72 -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
package/README.md
CHANGED
|
@@ -159,12 +159,12 @@ pi list
|
|
|
159
159
|
团队分发建议使用固定 Git tag:
|
|
160
160
|
|
|
161
161
|
```bash
|
|
162
|
-
pi install git:github.com/linyqh/pi-fpa@v0.9.
|
|
162
|
+
pi install git:github.com/linyqh/pi-fpa@v0.9.6
|
|
163
163
|
```
|
|
164
164
|
|
|
165
165
|
## 发布到 npm
|
|
166
166
|
|
|
167
|
-
发布动作由 GitHub Release 触发。Release 标签必须严格使用 `v<package.json version>`,例如版本 `0.9.
|
|
167
|
+
发布动作由 GitHub Release 触发。Release 标签必须严格使用 `v<package.json version>`,例如版本 `0.9.6` 对应 `v0.9.6`。工作流会检出该标签,执行 `npm ci`、`npm test` 和包内容预检,全部通过后发布公开包 `@viccydev/pi-fpa`。普通 Release 发布到 `latest`,Prerelease 发布到 `next`。
|
|
168
168
|
|
|
169
169
|
发布认证使用 npm Trusted Publishing / OIDC,不使用长期 npm Token。npm 包后台的 Trusted Publisher 配置为:
|
|
170
170
|
|
|
@@ -0,0 +1,359 @@
|
|
|
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 calibration module.
|
|
10
|
+
//
|
|
11
|
+
// The system already computed MAPE and then did nothing with it. A model
|
|
12
|
+
// accuracy figure that does not lead anywhere is decoration; the point of
|
|
13
|
+
// measuring error is to correct the assumption that produced it. So this
|
|
14
|
+
// module ends in a proposal — which baseline to move, by how much, worth how
|
|
15
|
+
// much over a year, and who has to approve it.
|
|
16
|
+
//
|
|
17
|
+
// Two things it must not do:
|
|
18
|
+
//
|
|
19
|
+
// * Treat a pending correction as applied. A proposal is a claim awaiting
|
|
20
|
+
// an approver, and showing it as fact would let the model silently
|
|
21
|
+
// re-baseline itself.
|
|
22
|
+
//
|
|
23
|
+
// * Present a partial decomposition as complete. When named factors do not
|
|
24
|
+
// account for the whole variance, the remainder is shown as unexplained.
|
|
25
|
+
// Dropping it makes the attribution look total when it is not.
|
|
26
|
+
// ============================================================================
|
|
27
|
+
|
|
28
|
+
/** Horizons are different claims about the same month; never pooled. */
|
|
29
|
+
const DEFAULT_HORIZON = 3;
|
|
30
|
+
|
|
31
|
+
export interface CalibrationOptions {
|
|
32
|
+
metric?: string;
|
|
33
|
+
horizonMonths?: number;
|
|
34
|
+
/** Which month to decompose. Defaults to the latest one with actuals. */
|
|
35
|
+
month?: string;
|
|
36
|
+
locale?: string;
|
|
37
|
+
signal?: AbortSignal;
|
|
38
|
+
cwd?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface CalibrationProjection {
|
|
42
|
+
module: DashboardModuleBuild;
|
|
43
|
+
mape: number | null;
|
|
44
|
+
/** Consecutive months the forecast erred in the same direction. */
|
|
45
|
+
biasRun: { direction: "over" | "under" | "none"; months: number };
|
|
46
|
+
pendingCorrections: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function currency(value: number | null, locale: string): string | null {
|
|
50
|
+
if (value === null) return null;
|
|
51
|
+
return new Intl.NumberFormat(locale, { style: "currency", currency: "USD", maximumFractionDigits: 0 }).format(value);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function percent(value: number | null, locale: string, digits = 1): string | null {
|
|
55
|
+
if (value === null) return null;
|
|
56
|
+
return new Intl.NumberFormat(locale, { style: "percent", maximumFractionDigits: digits }).format(value);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function toned(value: string | null, tone?: "positive" | "negative" | "warning" | "neutral"): TableCell {
|
|
60
|
+
return tone ? { value, tone } : value;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function num(row: Record<string, unknown> | undefined, key: string): number | null {
|
|
64
|
+
const value = row?.[key];
|
|
65
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function str(row: Record<string, unknown> | undefined, key: string): string | null {
|
|
69
|
+
const value = row?.[key];
|
|
70
|
+
return typeof value === "string" && value !== "" ? value : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* How many consecutive recent months erred the same way.
|
|
75
|
+
*
|
|
76
|
+
* A single bad month is noise; a run in one direction is a model that is
|
|
77
|
+
* wrong in a fixable way. That distinction is the whole trigger for proposing
|
|
78
|
+
* a baseline change, so it is computed rather than eyeballed.
|
|
79
|
+
*/
|
|
80
|
+
function biasRun(rows: Array<Record<string, unknown>>): { direction: "over" | "under" | "none"; months: number } {
|
|
81
|
+
const ordered = rows.slice().sort((left, right) => String(right.month).localeCompare(String(left.month)));
|
|
82
|
+
let direction: "over" | "under" | "none" = "none";
|
|
83
|
+
let months = 0;
|
|
84
|
+
for (const row of ordered) {
|
|
85
|
+
const variance = num(row, "variance");
|
|
86
|
+
if (variance === null || variance === 0) break;
|
|
87
|
+
// Actual below forecast means the forecast was optimistic.
|
|
88
|
+
const step = variance < 0 ? "over" : "under";
|
|
89
|
+
if (direction === "none") direction = step;
|
|
90
|
+
else if (direction !== step) break;
|
|
91
|
+
months += 1;
|
|
92
|
+
}
|
|
93
|
+
return months === 0 ? { direction: "none", months: 0 } : { direction, months };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function attributionWaterfall(
|
|
97
|
+
rows: Array<Record<string, unknown>>,
|
|
98
|
+
month: string,
|
|
99
|
+
forecast: number | null,
|
|
100
|
+
actual: number | null,
|
|
101
|
+
locale: string,
|
|
102
|
+
): WaterfallDataset {
|
|
103
|
+
const factors = rows
|
|
104
|
+
.slice()
|
|
105
|
+
.sort((left, right) => Math.abs(num(right, "impact_usd") ?? 0) - Math.abs(num(left, "impact_usd") ?? 0));
|
|
106
|
+
return {
|
|
107
|
+
label: "偏差归因",
|
|
108
|
+
description: "从预测到实际,逐项拆解差在哪里",
|
|
109
|
+
coverage: month,
|
|
110
|
+
steps: [
|
|
111
|
+
{ label: "预测", value: forecast, display: currency(forecast, locale), kind: "total" },
|
|
112
|
+
...factors.map((row) => {
|
|
113
|
+
const impact = num(row, "impact_usd");
|
|
114
|
+
const unexplained = str(row, "category") === "unexplained";
|
|
115
|
+
return {
|
|
116
|
+
label: str(row, "factor") ?? "(未命名)",
|
|
117
|
+
value: impact,
|
|
118
|
+
display: currency(impact, locale),
|
|
119
|
+
kind: "delta" as const,
|
|
120
|
+
// The residual is called out rather than blending in with
|
|
121
|
+
// the causes someone actually identified.
|
|
122
|
+
...(unexplained ? { tone: "warning" as const } : {}),
|
|
123
|
+
};
|
|
124
|
+
}),
|
|
125
|
+
{ label: "实际", value: actual, display: currency(actual, locale), kind: "total" },
|
|
126
|
+
],
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function projectCalibration(options: CalibrationOptions = {}): Promise<CalibrationProjection> {
|
|
131
|
+
const locale = options.locale ?? "zh-CN";
|
|
132
|
+
const metric = options.metric ?? "net_fcf";
|
|
133
|
+
const horizon = options.horizonMonths ?? DEFAULT_HORIZON;
|
|
134
|
+
const workspaceRoot = options.cwd ? dirname(await realpath(options.cwd)) : undefined;
|
|
135
|
+
const warnings: string[] = [];
|
|
136
|
+
|
|
137
|
+
const accuracy = await runStructuredQuery({
|
|
138
|
+
dataset: "forecast_vs_actual",
|
|
139
|
+
metrics: ["forecast_value", "actual_value", "variance", "variance_pct", "abs_pct_error"],
|
|
140
|
+
dimensions: ["month"],
|
|
141
|
+
filters: { metric, forecast_age_months: String(horizon) },
|
|
142
|
+
limit: 60,
|
|
143
|
+
}, options.signal, workspaceRoot);
|
|
144
|
+
const months = accuracy.rows.slice().sort((left, right) => String(left.month).localeCompare(String(right.month)));
|
|
145
|
+
if (months.length === 0) throw new Error(`No forecast-versus-actual rows for ${metric} at a ${horizon}-month horizon.`);
|
|
146
|
+
|
|
147
|
+
const errors = months.map((row) => num(row, "abs_pct_error")).filter((value): value is number => value !== null);
|
|
148
|
+
const mape = errors.length === 0 ? null : errors.reduce((sum, value) => sum + value, 0) / errors.length;
|
|
149
|
+
const run = biasRun(months);
|
|
150
|
+
const requested = options.month ? months.find((row) => String(row.month) === options.month) : undefined;
|
|
151
|
+
if (options.month && !requested) {
|
|
152
|
+
throw new Error(`No forecast-versus-actual row for ${options.month} at a ${horizon}-month horizon.`);
|
|
153
|
+
}
|
|
154
|
+
const latest = requested ?? months.at(-1)!;
|
|
155
|
+
const latestMonth = String(latest.month);
|
|
156
|
+
|
|
157
|
+
const attribution = await runStructuredQuery({
|
|
158
|
+
dataset: "variance_attribution",
|
|
159
|
+
metrics: ["impact_usd", "impact_share", "confidence"],
|
|
160
|
+
dimensions: ["factor", "category", "confidence_band"],
|
|
161
|
+
filters: { month: latestMonth, metric },
|
|
162
|
+
limit: 32,
|
|
163
|
+
}, options.signal, workspaceRoot);
|
|
164
|
+
|
|
165
|
+
const explained = attribution.rows
|
|
166
|
+
.filter((row) => str(row, "category") !== "unexplained")
|
|
167
|
+
.reduce((sum, row) => sum + (num(row, "impact_usd") ?? 0), 0);
|
|
168
|
+
const totalVariance = num(latest, "variance");
|
|
169
|
+
const coverage = totalVariance === null || totalVariance === 0 ? null : explained / totalVariance;
|
|
170
|
+
if (coverage !== null && coverage < 0.99) {
|
|
171
|
+
warnings.push(`${latestMonth} 的偏差仅 ${percent(coverage, locale)} 被具名因子解释,其余为未归因残差。`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (run.months >= 3) {
|
|
175
|
+
warnings.push(
|
|
176
|
+
`预测在最近 ${run.months} 个月连续${run.direction === "over" ? "高估" : "低估"},属于系统性偏差而非噪声,应校准基准假设。`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const assumptions = await runStructuredQuery({
|
|
181
|
+
dataset: "baseline_assumptions",
|
|
182
|
+
metrics: ["current_value", "suggested_value", "estimated_impact_usd_12m"],
|
|
183
|
+
dimensions: ["assumption_id", "assumption_name", "scope", "unit", "detection_basis", "affected_models", "approval_status", "approver_role"],
|
|
184
|
+
limit: 64,
|
|
185
|
+
}, options.signal, workspaceRoot);
|
|
186
|
+
const pending = assumptions.rows.filter((row) => str(row, "approval_status") === "pending");
|
|
187
|
+
if (pending.length > 0) {
|
|
188
|
+
warnings.push(`${pending.length} 项基准修正待审批,尚未生效——当前预测仍使用旧基准。`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const widgets: DashboardWidget[] = [
|
|
192
|
+
{
|
|
193
|
+
id: "calibration-mape",
|
|
194
|
+
type: "stat",
|
|
195
|
+
span: "quarter",
|
|
196
|
+
dataset: "calibration-mape.json",
|
|
197
|
+
data: {
|
|
198
|
+
label: "模型准确率",
|
|
199
|
+
value: mape === null ? null : percent(1 - mape, locale),
|
|
200
|
+
...(mape === null ? { missingReason: "尚无可比的已结账周期。" } : {}),
|
|
201
|
+
description: `MAPE ${percent(mape, locale) ?? "—"} · ${horizon} 个月前预测 · ${errors.length} 个样本`,
|
|
202
|
+
...(mape !== null
|
|
203
|
+
? { progress: { fraction: Math.max(0, Math.min(1, 1 - mape)), label: percent(1 - mape, locale) ?? "—", tone: (mape <= 0.05 ? "positive" : "warning") as "positive" | "warning" } }
|
|
204
|
+
: {}),
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
id: "calibration-bias",
|
|
209
|
+
type: "stat",
|
|
210
|
+
span: "quarter",
|
|
211
|
+
dataset: "calibration-bias.json",
|
|
212
|
+
data: {
|
|
213
|
+
label: "偏差方向",
|
|
214
|
+
value: run.direction === "none" ? "无连续偏差" : run.direction === "over" ? "连续高估" : "连续低估",
|
|
215
|
+
description: run.months > 0 ? `最近 ${run.months} 个月同向` : "最近一期与预测同向或持平",
|
|
216
|
+
delta: run.months >= 3
|
|
217
|
+
? { direction: "up" as const, label: `${run.months} 个月`, sentiment: "negative" as const }
|
|
218
|
+
: { direction: "flat" as const, label: `${run.months} 个月`, sentiment: "neutral" as const },
|
|
219
|
+
// A run is what separates a fixable model from a noisy month.
|
|
220
|
+
footnote: "连续 3 个月及以上同向即视为系统性偏差",
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
id: "calibration-variance",
|
|
225
|
+
type: "stat",
|
|
226
|
+
span: "quarter",
|
|
227
|
+
dataset: "calibration-variance.json",
|
|
228
|
+
data: {
|
|
229
|
+
label: `${latestMonth} 偏差`,
|
|
230
|
+
value: currency(totalVariance, locale),
|
|
231
|
+
description: `实际 ${currency(num(latest, "actual_value"), locale) ?? "—"} vs 预测 ${currency(num(latest, "forecast_value"), locale) ?? "—"}`,
|
|
232
|
+
...(num(latest, "variance_pct") !== null
|
|
233
|
+
? { delta: { direction: (num(latest, "variance_pct")! >= 0 ? "up" : "down") as "up" | "down", label: percent(num(latest, "variance_pct"), locale) ?? "", sentiment: (num(latest, "variance_pct")! >= 0 ? "positive" : "negative") as "positive" | "negative" } }
|
|
234
|
+
: {}),
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
id: "calibration-coverage",
|
|
239
|
+
type: "stat",
|
|
240
|
+
span: "quarter",
|
|
241
|
+
dataset: "calibration-coverage.json",
|
|
242
|
+
data: {
|
|
243
|
+
label: "归因覆盖率",
|
|
244
|
+
value: percent(coverage, locale),
|
|
245
|
+
...(coverage === null ? { missingReason: "该月偏差为零或不可计算。" } : {}),
|
|
246
|
+
...(coverage !== null
|
|
247
|
+
? { progress: { fraction: Math.max(0, Math.min(1, coverage)), label: percent(coverage, locale) ?? "—", tone: (coverage >= 0.99 ? "positive" : "warning") as "positive" | "warning" } }
|
|
248
|
+
: {}),
|
|
249
|
+
footnote: coverage !== null && coverage < 0.99 ? "其余为未归因残差,已在瀑布图中单列" : undefined,
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
id: "calibration-attribution",
|
|
254
|
+
type: "waterfall",
|
|
255
|
+
span: "full",
|
|
256
|
+
dataset: "calibration-attribution.json",
|
|
257
|
+
data: attributionWaterfall(attribution.rows, latestMonth, num(latest, "forecast_value"), num(latest, "actual_value"), locale),
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
id: "calibration-accuracy-trend",
|
|
261
|
+
type: "timeseries",
|
|
262
|
+
span: "full",
|
|
263
|
+
dataset: "calibration-accuracy-trend.json",
|
|
264
|
+
data: {
|
|
265
|
+
label: "预测与实际走势",
|
|
266
|
+
description: `${horizon} 个月前的预测 vs 最终实际`,
|
|
267
|
+
coverage: `${String(months[0].month)} → ${latestMonth}`,
|
|
268
|
+
series: [
|
|
269
|
+
{ name: "预测", points: months.map((row) => ({ x: String(row.month), y: num(row, "forecast_value") })) },
|
|
270
|
+
{ name: "实际", points: months.map((row) => ({ x: String(row.month), y: num(row, "actual_value") })) },
|
|
271
|
+
],
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
id: "calibration-factors",
|
|
276
|
+
type: "table",
|
|
277
|
+
span: "half",
|
|
278
|
+
dataset: "calibration-factors.json",
|
|
279
|
+
data: {
|
|
280
|
+
label: "归因因子",
|
|
281
|
+
description: `${latestMonth},按影响绝对值排序`,
|
|
282
|
+
columns: [
|
|
283
|
+
{ key: "factor", label: "因子" },
|
|
284
|
+
{ key: "impact", label: "影响", align: "right" },
|
|
285
|
+
{ key: "share", label: "占比", align: "right" },
|
|
286
|
+
{ key: "confidence", label: "置信" },
|
|
287
|
+
],
|
|
288
|
+
rows: attribution.rows
|
|
289
|
+
.slice()
|
|
290
|
+
.sort((left, right) => Math.abs(num(right, "impact_usd") ?? 0) - Math.abs(num(left, "impact_usd") ?? 0))
|
|
291
|
+
.map((row) => ({
|
|
292
|
+
factor: str(row, "factor"),
|
|
293
|
+
impact: currency(num(row, "impact_usd"), locale),
|
|
294
|
+
share: percent(num(row, "impact_share"), locale),
|
|
295
|
+
confidence: toned(
|
|
296
|
+
str(row, "category") === "unexplained" ? "未归因" : str(row, "confidence_band"),
|
|
297
|
+
str(row, "category") === "unexplained" ? "warning" : undefined,
|
|
298
|
+
),
|
|
299
|
+
})),
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
id: "calibration-assumptions",
|
|
304
|
+
type: "table",
|
|
305
|
+
span: "half",
|
|
306
|
+
dataset: "calibration-assumptions.json",
|
|
307
|
+
data: {
|
|
308
|
+
label: "基准假设与校准建议",
|
|
309
|
+
description: "待审批的修正尚未生效;当前预测仍使用现行基准",
|
|
310
|
+
columns: [
|
|
311
|
+
{ key: "name", label: "假设" },
|
|
312
|
+
{ key: "current", label: "现行", align: "right" },
|
|
313
|
+
{ key: "suggested", label: "建议", align: "right" },
|
|
314
|
+
{ key: "impact", label: "12 个月影响", align: "right" },
|
|
315
|
+
{ key: "basis", label: "依据" },
|
|
316
|
+
{ key: "status", label: "状态" },
|
|
317
|
+
],
|
|
318
|
+
rows: assumptions.rows.map((row) => {
|
|
319
|
+
const status = str(row, "approval_status");
|
|
320
|
+
return {
|
|
321
|
+
name: `${str(row, "assumption_name")}(${str(row, "scope")})`,
|
|
322
|
+
current: num(row, "current_value") === null ? null : String(num(row, "current_value")),
|
|
323
|
+
// No proposal is a null, not a repeat of the current value:
|
|
324
|
+
// echoing it would read as "we checked and it is fine".
|
|
325
|
+
suggested: num(row, "suggested_value") === null ? null : String(num(row, "suggested_value")),
|
|
326
|
+
impact: currency(num(row, "estimated_impact_usd_12m"), locale),
|
|
327
|
+
basis: str(row, "detection_basis"),
|
|
328
|
+
status: toned(
|
|
329
|
+
status === "pending" ? `待 ${str(row, "approver_role") ?? "审批人"} 审批` : status === "approved" ? "已批准" : "现行",
|
|
330
|
+
status === "pending" ? "warning" : status === "approved" ? "positive" : "neutral",
|
|
331
|
+
),
|
|
332
|
+
};
|
|
333
|
+
}),
|
|
334
|
+
},
|
|
335
|
+
},
|
|
336
|
+
];
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
module: {
|
|
340
|
+
id: "forecast-accuracy",
|
|
341
|
+
title: "偏差校准",
|
|
342
|
+
status: "published",
|
|
343
|
+
source: {
|
|
344
|
+
artifact_type: "finance_csv",
|
|
345
|
+
metric,
|
|
346
|
+
horizon_months: horizon,
|
|
347
|
+
sample_months: months.length,
|
|
348
|
+
latest_month: latestMonth,
|
|
349
|
+
datasets: ["forecast_vs_actual", "variance_attribution", "baseline_assumptions"],
|
|
350
|
+
data_as_of: latestMonth,
|
|
351
|
+
},
|
|
352
|
+
warnings,
|
|
353
|
+
widgets,
|
|
354
|
+
},
|
|
355
|
+
mape,
|
|
356
|
+
biasRun: run,
|
|
357
|
+
pendingCorrections: pending.length,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
@@ -7,6 +7,61 @@ import { publishDashboard, resolveDashboardDir } from "./publisher.ts";
|
|
|
7
7
|
import { publishDashboardModule, readDashboardModuleManifest } from "./module-publisher.ts";
|
|
8
8
|
import type { DashboardActualsSnapshot } from "./source.ts";
|
|
9
9
|
|
|
10
|
+
// ============================================================================
|
|
11
|
+
// Publishing the legacy closed-loop build into the modular dashboard.
|
|
12
|
+
//
|
|
13
|
+
// This build predates the modular split and computes everything at once:
|
|
14
|
+
// execution results, next-cycle allocation, forward outlook, forecast
|
|
15
|
+
// accuracy. Publishing all of it into one module made `execution-evidence` a
|
|
16
|
+
// container rather than a subject — and now that dedicated modules own most of
|
|
17
|
+
// those topics, it would also mean two producers writing the same content into
|
|
18
|
+
// the same dashboard.
|
|
19
|
+
//
|
|
20
|
+
// Re-homing the extra widgets into `next-forecast` and `forecast-accuracy` is
|
|
21
|
+
// not an option either: those modules have their own publishers, and this path
|
|
22
|
+
// would overwrite whatever they last wrote.
|
|
23
|
+
//
|
|
24
|
+
// So this publishes only what execution evidence actually is — was the
|
|
25
|
+
// approved plan carried out, and what did it produce — and names the tool that
|
|
26
|
+
// owns each topic it dropped. The build still computes them and they stay in
|
|
27
|
+
// the receipt's source, so nothing becomes unprovable; they simply stop being
|
|
28
|
+
// shown twice.
|
|
29
|
+
// ============================================================================
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The widgets whose subject is "the plan was executed, and here is the result".
|
|
33
|
+
* Anything else the legacy build produces belongs to a module with its own
|
|
34
|
+
* publisher.
|
|
35
|
+
*/
|
|
36
|
+
const EXECUTION_EVIDENCE_WIDGETS = new Set([
|
|
37
|
+
"current-cycle-execution-evidence",
|
|
38
|
+
"revenue-vs-forecast",
|
|
39
|
+
"cycle-roas",
|
|
40
|
+
"paid-spend-exec",
|
|
41
|
+
"daily-trend",
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
/** Which tool now owns each topic the legacy build also computes. */
|
|
45
|
+
const SUPERSEDED_BY: Array<{ match: (id: string) => boolean; module: string; tool: string }> = [
|
|
46
|
+
{
|
|
47
|
+
match: (id) => id.startsWith("forecast-accuracy-") || id === "calibration-alerts" || id === "alerts-count" || id === "closed-cycle-revenue-variance",
|
|
48
|
+
module: "forecast-accuracy",
|
|
49
|
+
tool: "fpa_dashboard_publish_calibration",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
match: (id) => id.startsWith("next-cycle-") || id.startsWith("forward-outlook-") || id.startsWith("forecast-data-") || id === "forecast-limitations" || id === "app-strategy",
|
|
53
|
+
module: "next-forecast",
|
|
54
|
+
tool: "fpa_dashboard_publish_forecast",
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
function ownerOf(widgetId: string): { module: string; tool: string } | null {
|
|
59
|
+
const entry = SUPERSEDED_BY.find((candidate) => candidate.match(widgetId));
|
|
60
|
+
// Return only the serialisable half: the whole entry carries a predicate,
|
|
61
|
+
// and this lands in the build receipt, which must stay plain JSON.
|
|
62
|
+
return entry ? { module: entry.module, tool: entry.tool } : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
10
65
|
interface Options {
|
|
11
66
|
cwd: string;
|
|
12
67
|
build: DashboardBuild;
|
|
@@ -28,6 +83,31 @@ export async function publishDashboardProjection(options: Options): Promise<{ da
|
|
|
28
83
|
if (options.build.source.forecast_role === "backtest" && options.allowBacktestDisplay !== true) {
|
|
29
84
|
throw new Error("A backtest cannot replace dashboard execution evidence without explicit user authorization.");
|
|
30
85
|
}
|
|
86
|
+
|
|
87
|
+
const owned = options.build.widgets.filter((widget) => EXECUTION_EVIDENCE_WIDGETS.has(widget.id));
|
|
88
|
+
if (owned.length === 0) {
|
|
89
|
+
throw new Error("The closed-loop build produced no execution-evidence widgets; there is nothing for this module to publish.");
|
|
90
|
+
}
|
|
91
|
+
const superseded = options.build.widgets
|
|
92
|
+
.filter((widget) => !EXECUTION_EVIDENCE_WIDGETS.has(widget.id))
|
|
93
|
+
.map((widget) => ({ id: widget.id, ...(ownerOf(widget.id) ?? { module: "unassigned", tool: "(none)" }) }));
|
|
94
|
+
|
|
95
|
+
const warnings = [...options.build.warnings];
|
|
96
|
+
const byTool = new Map<string, string[]>();
|
|
97
|
+
for (const entry of superseded) {
|
|
98
|
+
if (entry.tool === "(none)") continue;
|
|
99
|
+
byTool.set(entry.tool, [...(byTool.get(entry.tool) ?? []), entry.id]);
|
|
100
|
+
}
|
|
101
|
+
for (const [tool, ids] of byTool) {
|
|
102
|
+
warnings.push(`${ids.length} 项内容已由专用模块承载,本模块不再重复展示;请改用 ${tool} 发布:${ids.join("、")}。`);
|
|
103
|
+
}
|
|
104
|
+
const unassigned = superseded.filter((entry) => entry.tool === "(none)");
|
|
105
|
+
if (unassigned.length > 0) {
|
|
106
|
+
// A widget with no owner is a gap in the map above, not something to
|
|
107
|
+
// quietly swallow.
|
|
108
|
+
warnings.push(`${unassigned.length} 项内容没有归属模块,未被展示:${unassigned.map((entry) => entry.id).join("、")}。`);
|
|
109
|
+
}
|
|
110
|
+
|
|
31
111
|
const current = await readDashboardModuleManifest(options.cwd);
|
|
32
112
|
const published = await publishDashboardModule({
|
|
33
113
|
cwd: options.cwd,
|
|
@@ -35,10 +115,16 @@ export async function publishDashboardProjection(options: Options): Promise<{ da
|
|
|
35
115
|
expectedDashboardRevision: current?.dashboardRevision ?? null,
|
|
36
116
|
module: {
|
|
37
117
|
id: "execution-evidence",
|
|
38
|
-
title: "
|
|
118
|
+
title: "执行证据",
|
|
39
119
|
status: "published",
|
|
40
|
-
source: {
|
|
41
|
-
|
|
120
|
+
source: {
|
|
121
|
+
...options.build.source,
|
|
122
|
+
projector: options.projector,
|
|
123
|
+
runtime: options.runtime,
|
|
124
|
+
superseded_widgets: superseded,
|
|
125
|
+
},
|
|
126
|
+
warnings,
|
|
127
|
+
widgets: owned,
|
|
42
128
|
},
|
|
43
129
|
});
|
|
44
130
|
return { dashboardDir: published.dashboardDir, fingerprint: published.moduleRevision, publishedDatasets: published.publishedDatasets };
|