@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 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.5
162
+ pi install git:github.com/linyqh/pi-fpa@v0.9.7
163
163
  ```
164
164
 
165
165
  ## 发布到 npm
166
166
 
167
- 发布动作由 GitHub Release 触发。Release 标签必须严格使用 `v<package.json version>`,例如版本 `0.9.5` 对应 `v0.9.5`。工作流会检出该标签,执行 `npm ci`、`npm test` 和包内容预检,全部通过后发布公开包 `@viccydev/pi-fpa`。普通 Release 发布到 `latest`,Prerelease 发布到 `next`。
167
+ 发布动作由 GitHub Release 触发。Release 标签必须严格使用 `v<package.json version>`,例如版本 `0.9.7` 对应 `v0.9.7`。工作流会检出该标签,执行 `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
 
@@ -62,7 +62,12 @@ async function selectWorkspaceProject(root) {
62
62
  const canonicalRoot = await realpath(root);
63
63
  const entries = await readdir(canonicalRoot, { withFileTypes: true });
64
64
  for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
65
- if (!entry.isDirectory() || entry.isSymbolicLink() || entry.name === ".fpa-dashboard") continue;
65
+ // Skip every dot-directory, not just the dashboard's. A workspace also
66
+ // holds data directories the product owns (.fpa-finance, and whatever
67
+ // comes next), and they sort before real project names — excluding them
68
+ // one name at a time means the next one silently becomes "the project"
69
+ // and the real one stops being refreshed.
70
+ if (!entry.isDirectory() || entry.isSymbolicLink() || entry.name.startsWith(".")) continue;
66
71
  const project = await realpath(resolve(canonicalRoot, entry.name));
67
72
  if (dirname(project) === canonicalRoot) return project;
68
73
  }
@@ -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: { ...options.build.source, warnings: options.build.warnings, projector: options.projector, runtime: options.runtime },
41
- widgets: options.build.widgets,
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 };