@viccydev/pi-fpa 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -3
- package/extensions/fpa-artifacts/contracts.ts +624 -0
- package/extensions/fpa-artifacts/index.ts +42 -0
- package/extensions/fpa-artifacts/store.ts +129 -0
- package/extensions/fpa-dashboard/actuals.ts +185 -0
- package/extensions/fpa-dashboard/index.ts +145 -0
- package/extensions/fpa-dashboard/projector.ts +482 -0
- package/extensions/fpa-dashboard/publisher.ts +170 -0
- package/extensions/fpa-dashboard/schema.ts +115 -0
- package/extensions/fpa-dashboard/source.ts +152 -0
- package/extensions/fpa-dashboard/status.ts +154 -0
- package/extensions/fpa-data/index.ts +2 -27
- package/extensions/fpa-data/runtime.ts +22 -0
- package/extensions/fpa-data/sql.ts +32 -0
- package/package.json +6 -4
- package/skills/fpa-execute-approved-strategy/SKILL.md +2 -2
- package/skills/fpa-execute-approved-strategy/references/artifact-contract.md +14 -1
- package/skills/fpa-forecast-approved-strategy/SKILL.md +2 -2
- package/skills/fpa-forecast-approved-strategy/references/artifact-contract.md +14 -4
- package/skills/fpa-refresh-dashboard/SKILL.md +29 -0
- package/skills/fpa-refresh-dashboard/references/dashboard-policy.md +12 -0
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
import {
|
|
2
|
+
validateArtifact,
|
|
3
|
+
validateExecutionAgainstForecast,
|
|
4
|
+
sliceKey,
|
|
5
|
+
type ApprovedCycleForecastInput,
|
|
6
|
+
type ExecutionReceiptInput,
|
|
7
|
+
type ForecastAllocation,
|
|
8
|
+
type ForecastSlice,
|
|
9
|
+
} from "../fpa-artifacts/contracts.ts";
|
|
10
|
+
import { validateActualsSnapshot, type DashboardActualsSnapshot, type DashboardActualSlice } from "./source.ts";
|
|
11
|
+
|
|
12
|
+
export type Tone = "positive" | "negative" | "warning" | "neutral";
|
|
13
|
+
export type TableCell = string | null | { value: string | null; tone?: Tone };
|
|
14
|
+
|
|
15
|
+
export interface StatDataset {
|
|
16
|
+
label: string;
|
|
17
|
+
value: string | null;
|
|
18
|
+
missingReason?: string;
|
|
19
|
+
delta?: { direction: "up" | "down" | "flat"; label: string; sentiment?: "positive" | "negative" | "neutral" };
|
|
20
|
+
progress?: { fraction: number; label: string; tone?: Tone };
|
|
21
|
+
description?: string;
|
|
22
|
+
footnote?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface TimeseriesDataset {
|
|
26
|
+
label: string;
|
|
27
|
+
description?: string;
|
|
28
|
+
series: Array<{ name: string; points: Array<{ x: string; y: number | null }> }>;
|
|
29
|
+
coverage?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface TableDataset {
|
|
33
|
+
label: string;
|
|
34
|
+
description?: string;
|
|
35
|
+
columns: Array<{ key: string; label: string; align?: "left" | "right" }>;
|
|
36
|
+
rows: Array<Record<string, TableCell>>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface GroupedTableDataset {
|
|
40
|
+
label: string;
|
|
41
|
+
description?: string;
|
|
42
|
+
columns: Array<{ key: string; label: string; align?: "left" | "right" }>;
|
|
43
|
+
groups: Array<{
|
|
44
|
+
key: string;
|
|
45
|
+
label: string;
|
|
46
|
+
summary: Record<string, TableCell>;
|
|
47
|
+
rows: Array<Record<string, TableCell>>;
|
|
48
|
+
}>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type DashboardWidget =
|
|
52
|
+
| { id: string; type: "stat"; span: "quarter" | "half" | "full"; dataset: string; data: StatDataset }
|
|
53
|
+
| { id: string; type: "timeseries"; span: "quarter" | "half" | "full"; dataset: string; data: TimeseriesDataset }
|
|
54
|
+
| { id: string; type: "table"; span: "quarter" | "half" | "full"; dataset: string; data: TableDataset }
|
|
55
|
+
| { id: string; type: "grouped-table"; span: "quarter" | "half" | "full"; dataset: string; data: GroupedTableDataset };
|
|
56
|
+
|
|
57
|
+
export interface DashboardBuild {
|
|
58
|
+
title: string;
|
|
59
|
+
widgets: DashboardWidget[];
|
|
60
|
+
source: {
|
|
61
|
+
forecast_version: string;
|
|
62
|
+
forecast_fingerprint?: string;
|
|
63
|
+
execution_fingerprint?: string;
|
|
64
|
+
data_as_of: string;
|
|
65
|
+
actuals_scope: { slice_keys: string[] };
|
|
66
|
+
query_receipts: DashboardActualsSnapshot["query_receipts"];
|
|
67
|
+
};
|
|
68
|
+
warnings: string[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface ProjectionInput {
|
|
72
|
+
forecast: unknown;
|
|
73
|
+
actuals: unknown;
|
|
74
|
+
execution?: unknown;
|
|
75
|
+
locale?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface JoinedSlice {
|
|
79
|
+
allocation: ForecastAllocation;
|
|
80
|
+
forecast: ForecastSlice;
|
|
81
|
+
actual: DashboardActualSlice | null;
|
|
82
|
+
execution: ExecutionReceiptInput["slices"][number] | null;
|
|
83
|
+
forecastRoas: number | null;
|
|
84
|
+
actualRoas: number | null;
|
|
85
|
+
deviation: number | null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const ACTION_LABEL: Record<ForecastAllocation["action"], string> = {
|
|
89
|
+
stop: "止损",
|
|
90
|
+
decrease: "减量",
|
|
91
|
+
hold: "维持",
|
|
92
|
+
increase: "加量",
|
|
93
|
+
explore: "探索",
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
function round(value: number, precision = 3): number {
|
|
97
|
+
const factor = 10 ** precision;
|
|
98
|
+
return Math.round(value * factor) / factor;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function safeDiv(numerator: number | null, denominator: number | null): number | null {
|
|
102
|
+
if (numerator === null || denominator === null || denominator === 0) return null;
|
|
103
|
+
return numerator / denominator;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function clampFraction(value: number | null): number {
|
|
107
|
+
if (value === null || !Number.isFinite(value)) return 0;
|
|
108
|
+
return round(Math.max(0, Math.min(1, value)));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function formatCurrency(value: number | null, currency: string, locale: string): string | null {
|
|
112
|
+
if (value === null) return null;
|
|
113
|
+
return new Intl.NumberFormat(locale, {
|
|
114
|
+
style: "currency",
|
|
115
|
+
currency,
|
|
116
|
+
currencyDisplay: "narrowSymbol",
|
|
117
|
+
minimumFractionDigits: 0,
|
|
118
|
+
maximumFractionDigits: 2,
|
|
119
|
+
}).format(value);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function formatRoas(value: number | null): string | null {
|
|
123
|
+
return value === null ? null : value.toFixed(2);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function formatSignedPercent(value: number | null, suffix = ""): string | null {
|
|
127
|
+
if (value === null) return null;
|
|
128
|
+
const sign = value > 0 ? "+" : "";
|
|
129
|
+
return `${sign}${(value * 100).toFixed(1)}%${suffix}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function formatPeriodDate(timestamp: string, timezone: string, locale: string): string {
|
|
133
|
+
return new Intl.DateTimeFormat(locale, {
|
|
134
|
+
timeZone: timezone,
|
|
135
|
+
year: "numeric",
|
|
136
|
+
month: "numeric",
|
|
137
|
+
day: "numeric",
|
|
138
|
+
}).format(new Date(timestamp));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function samePeriod(a: ApprovedCycleForecastInput["target_period"], b: DashboardActualsSnapshot["period"]): boolean {
|
|
142
|
+
return a.start_inclusive === b.start_inclusive && a.end_exclusive === b.end_exclusive && a.timezone === b.timezone;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function actionCell(action: ForecastAllocation["action"]): TableCell {
|
|
146
|
+
const tone: Tone | undefined = action === "stop" ? "negative" : action === "increase" ? "positive" : action === "decrease" ? "warning" : undefined;
|
|
147
|
+
return tone ? { value: ACTION_LABEL[action], tone } : ACTION_LABEL[action];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function executionLabel(
|
|
151
|
+
receipt: ExecutionReceiptInput | null,
|
|
152
|
+
slice: ExecutionReceiptInput["slices"][number] | null,
|
|
153
|
+
): TableCell {
|
|
154
|
+
if (!receipt) return "未提供回执";
|
|
155
|
+
if (!slice) return { value: "回执缺分片", tone: "warning" };
|
|
156
|
+
if (receipt.verification_status === "verified") return { value: "已核验", tone: "positive" };
|
|
157
|
+
if (receipt.verification_status === "failed") return { value: "执行失败", tone: "negative" };
|
|
158
|
+
return receipt.execution_mode === "manual" ? "人工已报告" : "适配器已报告";
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function executionGroupLabel(receipt: ExecutionReceiptInput | null, slices: JoinedSlice[]): TableCell {
|
|
162
|
+
if (!receipt) return "未提供回执";
|
|
163
|
+
if (slices.some((slice) => slice.execution === null)) return { value: "回执缺分片", tone: "warning" };
|
|
164
|
+
return executionLabel(receipt, slices[0]?.execution ?? null);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function numericCell(value: string | null, tone?: Tone): TableCell {
|
|
168
|
+
return tone ? { value, tone } : value;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function progressTone(fraction: number | null): Tone {
|
|
172
|
+
if (fraction === null) return "neutral";
|
|
173
|
+
if (fraction >= 1) return "positive";
|
|
174
|
+
if (fraction >= 0.9) return "warning";
|
|
175
|
+
return "negative";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function joinSlices(
|
|
179
|
+
forecast: ApprovedCycleForecastInput,
|
|
180
|
+
actuals: DashboardActualsSnapshot,
|
|
181
|
+
execution: ExecutionReceiptInput | null,
|
|
182
|
+
): JoinedSlice[] {
|
|
183
|
+
const forecastByKey = new Map(forecast.forecast_by_slice.map((slice) => [sliceKey(slice), slice]));
|
|
184
|
+
const actualByKey = new Map(actuals.slices.map((slice) => [sliceKey(slice), slice]));
|
|
185
|
+
const executionByKey = new Map((execution?.slices ?? []).map((slice) => [sliceKey(slice), slice]));
|
|
186
|
+
return forecast.approved_allocation.map((allocation) => {
|
|
187
|
+
const forecastSlice = forecastByKey.get(sliceKey(allocation));
|
|
188
|
+
if (!forecastSlice) throw new Error(`Forecast slice is missing for ${allocation.app_id} / ${allocation.store} / ${allocation.channel_group}.`);
|
|
189
|
+
const actual = actualByKey.get(sliceKey(allocation)) ?? null;
|
|
190
|
+
const forecastRoas = safeDiv(
|
|
191
|
+
forecastSlice.metrics.revenue?.base ?? null,
|
|
192
|
+
forecastSlice.metrics.spend?.base ?? null,
|
|
193
|
+
);
|
|
194
|
+
const actualRoas = actual ? safeDiv(actual.revenue, actual.spend) : null;
|
|
195
|
+
return {
|
|
196
|
+
allocation,
|
|
197
|
+
forecast: forecastSlice,
|
|
198
|
+
actual,
|
|
199
|
+
execution: executionByKey.get(sliceKey(allocation)) ?? null,
|
|
200
|
+
forecastRoas,
|
|
201
|
+
actualRoas,
|
|
202
|
+
deviation: forecastRoas === null || actualRoas === null ? null : safeDiv(actualRoas - forecastRoas, Math.abs(forecastRoas)),
|
|
203
|
+
};
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function buildStrategyTable(
|
|
208
|
+
joined: JoinedSlice[],
|
|
209
|
+
executionReceipt: ExecutionReceiptInput | null,
|
|
210
|
+
forecast: ApprovedCycleForecastInput,
|
|
211
|
+
): GroupedTableDataset {
|
|
212
|
+
const grouped = new Map<string, JoinedSlice[]>();
|
|
213
|
+
for (const slice of joined) grouped.set(slice.allocation.app_id, [...(grouped.get(slice.allocation.app_id) ?? []), slice]);
|
|
214
|
+
const columns = [
|
|
215
|
+
{ key: "unit", label: "App / 渠道" },
|
|
216
|
+
{ key: "forecast", label: "预测 ROAS", align: "right" as const },
|
|
217
|
+
{ key: "actual", label: "实际 ROAS", align: "right" as const },
|
|
218
|
+
{ key: "deviation", label: "偏差", align: "right" as const },
|
|
219
|
+
{ key: "decision", label: "决策" },
|
|
220
|
+
{ key: "exec", label: "执行状态" },
|
|
221
|
+
];
|
|
222
|
+
return {
|
|
223
|
+
label: "预测 vs 实际 · 执行策略(按 App)",
|
|
224
|
+
description:
|
|
225
|
+
`同周期付费 ROAS;偏差绝对值 ≥ ${(forecast.calibration_policy.deviation_warning_abs_gte * 100).toFixed(0)}% 标黄,` +
|
|
226
|
+
`> ${(forecast.calibration_policy.deviation_trigger_abs_gt * 100).toFixed(0)}% 触发校准。`,
|
|
227
|
+
columns,
|
|
228
|
+
groups: [...grouped.entries()].map(([appId, slices]) => {
|
|
229
|
+
const forecastRevenue = slices.every((slice) => slice.forecast.metrics.revenue?.base != null)
|
|
230
|
+
? slices.reduce((sum, slice) => sum + (slice.forecast.metrics.revenue?.base ?? 0), 0)
|
|
231
|
+
: null;
|
|
232
|
+
const forecastSpend = slices.every((slice) => slice.forecast.metrics.spend?.base != null)
|
|
233
|
+
? slices.reduce((sum, slice) => sum + (slice.forecast.metrics.spend?.base ?? 0), 0)
|
|
234
|
+
: null;
|
|
235
|
+
const actualRevenue = slices.every((slice) => slice.actual?.revenue != null)
|
|
236
|
+
? slices.reduce((sum, slice) => sum + (slice.actual?.revenue ?? 0), 0)
|
|
237
|
+
: null;
|
|
238
|
+
const actualSpend = slices.every((slice) => slice.actual?.spend != null)
|
|
239
|
+
? slices.reduce((sum, slice) => sum + (slice.actual?.spend ?? 0), 0)
|
|
240
|
+
: null;
|
|
241
|
+
const appForecast = safeDiv(forecastRevenue, forecastSpend);
|
|
242
|
+
const appActual = safeDiv(actualRevenue, actualSpend);
|
|
243
|
+
const appDeviation = appForecast === null || appActual === null ? null : safeDiv(appActual - appForecast, Math.abs(appForecast));
|
|
244
|
+
const actions = new Set(slices.map((slice) => slice.allocation.action));
|
|
245
|
+
const summaryAction = actions.size === 1 ? ACTION_LABEL[slices[0].allocation.action] : actions.has("stop") ? "部分止损" : "组合策略";
|
|
246
|
+
const summaryTone: Tone | undefined = actions.has("stop") ? "negative" : actions.has("increase") ? "positive" : undefined;
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
key: appId,
|
|
250
|
+
label: appId,
|
|
251
|
+
summary: {
|
|
252
|
+
forecast: formatRoas(appForecast),
|
|
253
|
+
actual: formatRoas(appActual),
|
|
254
|
+
deviation: numericCell(formatSignedPercent(appDeviation), appDeviation !== null && Math.abs(appDeviation) >= forecast.calibration_policy.deviation_warning_abs_gte ? "warning" : undefined),
|
|
255
|
+
decision: summaryTone ? { value: summaryAction, tone: summaryTone } : summaryAction,
|
|
256
|
+
exec: executionGroupLabel(executionReceipt, slices),
|
|
257
|
+
},
|
|
258
|
+
rows: slices.map((slice) => {
|
|
259
|
+
const stop = slice.actualRoas !== null && slice.actualRoas < forecast.calibration_policy.stop_loss_roas_lt;
|
|
260
|
+
const warn = slice.deviation !== null && Math.abs(slice.deviation) >= forecast.calibration_policy.deviation_warning_abs_gte;
|
|
261
|
+
return {
|
|
262
|
+
unit: slice.allocation.channel_group,
|
|
263
|
+
forecast: formatRoas(slice.forecastRoas),
|
|
264
|
+
actual: numericCell(formatRoas(slice.actualRoas), stop ? "negative" : undefined),
|
|
265
|
+
deviation: numericCell(formatSignedPercent(slice.deviation), stop ? "negative" : warn ? "warning" : undefined),
|
|
266
|
+
decision: actionCell(slice.allocation.action),
|
|
267
|
+
exec: executionLabel(executionReceipt, slice.execution),
|
|
268
|
+
};
|
|
269
|
+
}),
|
|
270
|
+
};
|
|
271
|
+
}),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function buildAlerts(joined: JoinedSlice[], forecast: ApprovedCycleForecastInput): TableDataset {
|
|
276
|
+
const rows: Array<Record<string, TableCell>> = [];
|
|
277
|
+
for (const slice of joined) {
|
|
278
|
+
if (slice.actualRoas === null) continue;
|
|
279
|
+
const unit = `${slice.allocation.app_id} × ${slice.allocation.channel_group}`;
|
|
280
|
+
if (slice.actualRoas < forecast.calibration_policy.stop_loss_roas_lt) {
|
|
281
|
+
rows.push({
|
|
282
|
+
level: { value: "严重", tone: "negative" },
|
|
283
|
+
unit,
|
|
284
|
+
trigger: `实际 ROAS ${formatRoas(slice.actualRoas)} < ${formatRoas(forecast.calibration_policy.stop_loss_roas_lt)} 止损线`,
|
|
285
|
+
action: "执行止损规则并核验预算回流",
|
|
286
|
+
});
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (slice.deviation !== null && Math.abs(slice.deviation) >= forecast.calibration_policy.deviation_warning_abs_gte) {
|
|
290
|
+
const isTrigger = Math.abs(slice.deviation) > forecast.calibration_policy.deviation_trigger_abs_gt;
|
|
291
|
+
rows.push({
|
|
292
|
+
level: { value: isTrigger ? "严重" : "警告", tone: isTrigger ? "negative" : "warning" },
|
|
293
|
+
unit,
|
|
294
|
+
trigger: `${isTrigger ? "偏差超校准阈值" : "偏差接近阈值"}:${formatSignedPercent(slice.deviation)}`,
|
|
295
|
+
action: "更新回收假设并进入下周期复盘",
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
label: "校准触发",
|
|
301
|
+
description: `规则版本 ${forecast.calibration_policy.policy_version};仅基于可计算的同口径单元。`,
|
|
302
|
+
columns: [
|
|
303
|
+
{ key: "level", label: "级别" },
|
|
304
|
+
{ key: "unit", label: "对象" },
|
|
305
|
+
{ key: "trigger", label: "触发条件" },
|
|
306
|
+
{ key: "action", label: "建议动作" },
|
|
307
|
+
],
|
|
308
|
+
rows,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function projectDashboard(input: ProjectionInput): DashboardBuild {
|
|
313
|
+
const normalizedForecast = validateArtifact(input.forecast);
|
|
314
|
+
if (normalizedForecast.artifact_type !== "approved_cycle_forecast") throw new Error("forecast must be an approved_cycle_forecast artifact.");
|
|
315
|
+
const forecast = normalizedForecast;
|
|
316
|
+
const actuals = validateActualsSnapshot(input.actuals);
|
|
317
|
+
const executionArtifact = input.execution === undefined ? null : validateArtifact(input.execution);
|
|
318
|
+
if (executionArtifact && executionArtifact.artifact_type !== "execution_receipt") throw new Error("execution must be an execution_receipt artifact.");
|
|
319
|
+
const execution = executionArtifact;
|
|
320
|
+
const locale = input.locale ?? "zh-CN";
|
|
321
|
+
if (!samePeriod(forecast.target_period, actuals.period)) throw new Error("Forecast and Actuals periods/timezones do not match.");
|
|
322
|
+
if (forecast.reporting_currency !== actuals.reporting_currency) throw new Error("Forecast and Actuals reporting currencies do not match.");
|
|
323
|
+
if (execution) validateExecutionAgainstForecast(execution, forecast);
|
|
324
|
+
if (execution && !samePeriod(forecast.target_period, execution.target_period)) throw new Error("Execution receipt period does not match the approved forecast.");
|
|
325
|
+
if (execution && execution.reporting_currency !== forecast.reporting_currency) throw new Error("Execution receipt currency does not match the approved forecast.");
|
|
326
|
+
|
|
327
|
+
const joined = joinSlices(forecast, actuals, execution);
|
|
328
|
+
const forecastRevenue = forecast.consolidated_forecast.revenue?.base ?? null;
|
|
329
|
+
const forecastSpend = forecast.consolidated_forecast.spend?.base ?? null;
|
|
330
|
+
const forecastRoas = safeDiv(forecastRevenue, forecastSpend);
|
|
331
|
+
const actualRoas = safeDiv(actuals.current.revenue, actuals.current.spend);
|
|
332
|
+
const revenueAttainment = safeDiv(actuals.current.revenue, forecastRevenue);
|
|
333
|
+
const spendAttainment = safeDiv(actuals.current.spend, forecastSpend);
|
|
334
|
+
const roasAttainment = safeDiv(actualRoas, forecastRoas);
|
|
335
|
+
const roasDeviation = actualRoas === null || forecastRoas === null
|
|
336
|
+
? null
|
|
337
|
+
: safeDiv(actualRoas - forecastRoas, Math.abs(forecastRoas));
|
|
338
|
+
const revenueComparison = actuals.comparison ? safeDiv(
|
|
339
|
+
actuals.current.revenue === null || actuals.comparison.revenue === null ? null : actuals.current.revenue - actuals.comparison.revenue,
|
|
340
|
+
actuals.comparison.revenue === null ? null : Math.abs(actuals.comparison.revenue),
|
|
341
|
+
) : null;
|
|
342
|
+
const alerts = buildAlerts(joined, forecast);
|
|
343
|
+
const evaluated = joined.filter((slice) => slice.actualRoas !== null).length;
|
|
344
|
+
const stopCount = joined.filter((slice) => slice.actualRoas !== null && slice.actualRoas < forecast.calibration_policy.stop_loss_roas_lt).length;
|
|
345
|
+
const deviationCount = alerts.rows.length - stopCount;
|
|
346
|
+
const window = `${formatPeriodDate(forecast.target_period.start_inclusive, forecast.target_period.timezone, locale)} → ${formatPeriodDate(forecast.target_period.end_exclusive, forecast.target_period.timezone, locale)}(end exclusive)`;
|
|
347
|
+
|
|
348
|
+
const widgets: DashboardWidget[] = [
|
|
349
|
+
{
|
|
350
|
+
id: "revenue-vs-forecast",
|
|
351
|
+
type: "stat",
|
|
352
|
+
span: "quarter",
|
|
353
|
+
dataset: "revenue-vs-forecast.json",
|
|
354
|
+
data: {
|
|
355
|
+
label: "本周期收入(付费口径)",
|
|
356
|
+
value: formatCurrency(actuals.current.revenue, forecast.reporting_currency, locale),
|
|
357
|
+
...(actuals.current.revenue === null ? { missingReason: "当前周期收入不可计算。" } : {}),
|
|
358
|
+
...(revenueComparison === null ? {} : {
|
|
359
|
+
delta: {
|
|
360
|
+
direction: revenueComparison > 0 ? "up" as const : revenueComparison < 0 ? "down" as const : "flat" as const,
|
|
361
|
+
label: formatSignedPercent(revenueComparison, " vs 等长上周期") as string,
|
|
362
|
+
sentiment: revenueComparison > 0 ? "positive" as const : revenueComparison < 0 ? "negative" as const : "neutral" as const,
|
|
363
|
+
},
|
|
364
|
+
}),
|
|
365
|
+
...(forecastRevenue === null || revenueAttainment === null ? {} : {
|
|
366
|
+
progress: { fraction: clampFraction(revenueAttainment), label: formatSignedPercent(revenueAttainment)?.replace(/^\+/, "") ?? "无数据", tone: progressTone(revenueAttainment) },
|
|
367
|
+
description: `冻结预测 ${formatCurrency(forecastRevenue, forecast.reporting_currency, locale)}`,
|
|
368
|
+
}),
|
|
369
|
+
footnote: `${window};Actuals as of ${actuals.data_as_of}`,
|
|
370
|
+
},
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
id: "cycle-roas",
|
|
374
|
+
type: "stat",
|
|
375
|
+
span: "quarter",
|
|
376
|
+
dataset: "cycle-roas.json",
|
|
377
|
+
data: {
|
|
378
|
+
label: "本周期 ROAS",
|
|
379
|
+
value: formatRoas(actualRoas),
|
|
380
|
+
...(actualRoas === null ? { missingReason: "收入或花费缺失,无法计算 ROAS。" } : {}),
|
|
381
|
+
...(roasDeviation === null ? {} : {
|
|
382
|
+
delta: {
|
|
383
|
+
direction: actualRoas > forecastRoas ? "up" as const : actualRoas < forecastRoas ? "down" as const : "flat" as const,
|
|
384
|
+
label: `${formatSignedPercent(roasDeviation)} vs 预测`,
|
|
385
|
+
sentiment: actualRoas >= forecastRoas ? "positive" as const : "negative" as const,
|
|
386
|
+
},
|
|
387
|
+
progress: { fraction: clampFraction(roasAttainment), label: formatSignedPercent(roasAttainment)?.replace(/^\+/, "") ?? "无数据", tone: progressTone(roasAttainment) },
|
|
388
|
+
description: `冻结预测目标 ${formatRoas(forecastRoas)}`,
|
|
389
|
+
}),
|
|
390
|
+
footnote: "ROAS = 聚合后 revenue / spend,非分片 ROAS 平均值",
|
|
391
|
+
},
|
|
392
|
+
},
|
|
393
|
+
{
|
|
394
|
+
id: "paid-spend-exec",
|
|
395
|
+
type: "stat",
|
|
396
|
+
span: "quarter",
|
|
397
|
+
dataset: "paid-spend-exec.json",
|
|
398
|
+
data: {
|
|
399
|
+
label: "本周期花费(付费口径)",
|
|
400
|
+
value: formatCurrency(actuals.current.spend, forecast.reporting_currency, locale),
|
|
401
|
+
...(actuals.current.spend === null ? { missingReason: "当前周期花费不可计算。" } : {}),
|
|
402
|
+
...(forecastSpend === null || spendAttainment === null ? {} : {
|
|
403
|
+
progress: { fraction: clampFraction(spendAttainment), label: formatSignedPercent(spendAttainment)?.replace(/^\+/, "") ?? "无数据", tone: spendAttainment !== null && spendAttainment > 1 ? "negative" : progressTone(spendAttainment) },
|
|
404
|
+
description: `周期预算 ${formatCurrency(forecastSpend, forecast.reporting_currency, locale)}`,
|
|
405
|
+
}),
|
|
406
|
+
footnote: execution ? `执行回执:${execution.verification_status}` : "未提供执行回执",
|
|
407
|
+
},
|
|
408
|
+
},
|
|
409
|
+
{
|
|
410
|
+
id: "alerts-count",
|
|
411
|
+
type: "stat",
|
|
412
|
+
span: "quarter",
|
|
413
|
+
dataset: "alerts-count.json",
|
|
414
|
+
data: {
|
|
415
|
+
label: "校准预警",
|
|
416
|
+
value: String(alerts.rows.length),
|
|
417
|
+
progress: { fraction: evaluated === 0 ? 0 : round(alerts.rows.length / evaluated), label: `${alerts.rows.length}/${evaluated}`, tone: alerts.rows.length > 0 ? "negative" : "positive" },
|
|
418
|
+
description: `${stopCount} 条止损触发 · ${deviationCount} 条偏差预警`,
|
|
419
|
+
footnote: `规则版本 ${forecast.calibration_policy.policy_version}`,
|
|
420
|
+
},
|
|
421
|
+
},
|
|
422
|
+
{
|
|
423
|
+
id: "daily-trend",
|
|
424
|
+
type: "timeseries",
|
|
425
|
+
span: "full",
|
|
426
|
+
dataset: "daily-trend.json",
|
|
427
|
+
data: {
|
|
428
|
+
label: "周期内收入与花费(付费口径)",
|
|
429
|
+
description: "缺失观测保持为 null,不做填补。",
|
|
430
|
+
series: [
|
|
431
|
+
{ name: "收入", points: actuals.daily.map((point) => ({ x: point.date, y: point.revenue })) },
|
|
432
|
+
{ name: "花费", points: actuals.daily.map((point) => ({ x: point.date, y: point.spend })) },
|
|
433
|
+
],
|
|
434
|
+
...(actuals.coverage.date_min && actuals.coverage.date_max ? { coverage: `${actuals.coverage.date_min} → ${actuals.coverage.date_max}` } : {}),
|
|
435
|
+
},
|
|
436
|
+
},
|
|
437
|
+
{
|
|
438
|
+
id: "app-strategy",
|
|
439
|
+
type: "grouped-table",
|
|
440
|
+
span: "full",
|
|
441
|
+
dataset: "app-strategy.json",
|
|
442
|
+
data: buildStrategyTable(joined, execution, forecast),
|
|
443
|
+
},
|
|
444
|
+
{
|
|
445
|
+
id: "calibration-alerts",
|
|
446
|
+
type: "table",
|
|
447
|
+
span: "full",
|
|
448
|
+
dataset: "calibration-alerts.json",
|
|
449
|
+
data: alerts,
|
|
450
|
+
},
|
|
451
|
+
];
|
|
452
|
+
|
|
453
|
+
const start = new Date(forecast.target_period.start_inclusive);
|
|
454
|
+
const plannedKeys = new Set(forecast.approved_allocation.map(sliceKey));
|
|
455
|
+
const executionKeys = new Set((execution?.slices ?? []).map(sliceKey));
|
|
456
|
+
const missingExecutionSlices = execution ? [...plannedKeys].filter((planned) => !executionKeys.has(planned)).length : 0;
|
|
457
|
+
const extraExecutionSlices = execution ? [...executionKeys].filter((executed) => !plannedKeys.has(executed)).length : 0;
|
|
458
|
+
const titleMonth = new Intl.DateTimeFormat(locale, {
|
|
459
|
+
timeZone: forecast.target_period.timezone,
|
|
460
|
+
year: "numeric",
|
|
461
|
+
month: "long",
|
|
462
|
+
}).format(start);
|
|
463
|
+
return {
|
|
464
|
+
title: `${titleMonth}经营看板 · 预测闭环`,
|
|
465
|
+
widgets,
|
|
466
|
+
source: {
|
|
467
|
+
forecast_version: forecast.forecast_version,
|
|
468
|
+
data_as_of: actuals.data_as_of,
|
|
469
|
+
actuals_scope: {
|
|
470
|
+
slice_keys: forecast.approved_allocation.map((slice) => sliceKey(slice).replaceAll("\u0000", " / ")).sort(),
|
|
471
|
+
},
|
|
472
|
+
query_receipts: actuals.query_receipts,
|
|
473
|
+
},
|
|
474
|
+
warnings: [
|
|
475
|
+
...(forecast.status !== "complete" ? [`Approved forecast status is ${forecast.status}.`] : []),
|
|
476
|
+
...(!forecast.approval_conditions_satisfied ? ["Approved forecast conditions are not satisfied."] : []),
|
|
477
|
+
...(execution?.verification_status === "reported" ? ["Execution is reported and not externally verified."] : []),
|
|
478
|
+
...(missingExecutionSlices > 0 ? [`Execution receipt is missing ${missingExecutionSlices} approved slices.`] : []),
|
|
479
|
+
...(extraExecutionSlices > 0 ? [`Execution receipt contains ${extraExecutionSlices} slices outside the approved allocation.`] : []),
|
|
480
|
+
],
|
|
481
|
+
};
|
|
482
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { lstat, mkdir, open, realpath, rename, unlink } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { stableJson } from "../fpa-artifacts/store.ts";
|
|
6
|
+
import type { DashboardBuild, DashboardWidget } from "./projector.ts";
|
|
7
|
+
import { validateDatasetForWidget } from "./schema.ts";
|
|
8
|
+
|
|
9
|
+
const DATASET_FILE_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}\.json$/;
|
|
10
|
+
|
|
11
|
+
export interface PublishDashboardOptions {
|
|
12
|
+
cwd: string;
|
|
13
|
+
build: DashboardBuild;
|
|
14
|
+
publishedAt?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface PublishDashboardResult {
|
|
18
|
+
dashboardDir: string;
|
|
19
|
+
fingerprint: string;
|
|
20
|
+
publishedDatasets: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface PublishedDataset {
|
|
24
|
+
logical: string;
|
|
25
|
+
filename: string;
|
|
26
|
+
sha256: string;
|
|
27
|
+
contents: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function sha256(value: string): string {
|
|
31
|
+
return createHash("sha256").update(value).digest("hex");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function assertBuild(build: DashboardBuild): void {
|
|
35
|
+
if (!build || typeof build !== "object") throw new Error("Dashboard build is required.");
|
|
36
|
+
if (typeof build.title !== "string" || build.title.trim() === "") throw new Error("Dashboard title is required.");
|
|
37
|
+
if (!Array.isArray(build.widgets) || build.widgets.length === 0 || build.widgets.length > 64) {
|
|
38
|
+
throw new Error("Dashboard must contain between 1 and 64 widgets.");
|
|
39
|
+
}
|
|
40
|
+
const ids = new Set<string>();
|
|
41
|
+
const datasets = new Set<string>();
|
|
42
|
+
for (const widget of build.widgets) {
|
|
43
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(widget.id) || ids.has(widget.id)) throw new Error(`Invalid or duplicate dashboard widget id "${widget.id}".`);
|
|
44
|
+
ids.add(widget.id);
|
|
45
|
+
if (widget.type !== "stat" && widget.type !== "timeseries" && widget.type !== "table" && widget.type !== "grouped-table") throw new Error(`Unsupported dashboard widget type "${widget.type}".`);
|
|
46
|
+
if (widget.span !== "quarter" && widget.span !== "half" && widget.span !== "full") throw new Error(`Unsupported dashboard widget span "${widget.span}".`);
|
|
47
|
+
if (!DATASET_FILE_RE.test(widget.dataset)) throw new Error(`Invalid logical dataset name "${widget.dataset}".`);
|
|
48
|
+
if (datasets.has(widget.dataset)) throw new Error(`Duplicate logical dataset name "${widget.dataset}".`);
|
|
49
|
+
datasets.add(widget.dataset);
|
|
50
|
+
try {
|
|
51
|
+
validateDatasetForWidget(widget.type, widget.data);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
throw new Error(`Widget "${widget.id}" dataset schema is invalid: ${error instanceof Error ? error.message : String(error)}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function contentAddressDataset(widget: DashboardWidget): PublishedDataset {
|
|
59
|
+
const contents = `${JSON.stringify(widget.data, null, 2)}\n`;
|
|
60
|
+
const digest = sha256(stableJson(widget.data));
|
|
61
|
+
const logicalBase = widget.dataset.slice(0, -".json".length);
|
|
62
|
+
const filename = `${logicalBase}.${digest.slice(0, 12)}.json`;
|
|
63
|
+
if (!DATASET_FILE_RE.test(filename)) {
|
|
64
|
+
throw new Error(`Content-addressed dataset name "${filename}" exceeds the dashboard contract.`);
|
|
65
|
+
}
|
|
66
|
+
return { logical: widget.dataset, filename, sha256: digest, contents };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function pathKind(path: string): Promise<"missing" | "directory" | "symlink" | "other"> {
|
|
70
|
+
try {
|
|
71
|
+
const stat = await lstat(path);
|
|
72
|
+
if (stat.isSymbolicLink()) return "symlink";
|
|
73
|
+
if (stat.isDirectory()) return "directory";
|
|
74
|
+
return "other";
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return "missing";
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function ensureOwnedDirectory(path: string, label: string): Promise<void> {
|
|
82
|
+
const kind = await pathKind(path);
|
|
83
|
+
if (kind === "symlink") throw new Error(`${label} must not be a symlink.`);
|
|
84
|
+
if (kind === "other") throw new Error(`${label} must be a directory.`);
|
|
85
|
+
if (kind === "missing") await mkdir(path, { recursive: true, mode: 0o700 });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function atomicWrite(destination: string, contents: string): Promise<void> {
|
|
89
|
+
const temporary = join(dirname(destination), `.${basename(destination)}.${randomUUID()}.tmp`);
|
|
90
|
+
const handle = await open(temporary, "wx", 0o600);
|
|
91
|
+
let closed = false;
|
|
92
|
+
try {
|
|
93
|
+
await handle.writeFile(contents, "utf8");
|
|
94
|
+
await handle.sync();
|
|
95
|
+
await handle.close();
|
|
96
|
+
closed = true;
|
|
97
|
+
await rename(temporary, destination);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (!closed) await handle.close().catch(() => undefined);
|
|
100
|
+
await unlink(temporary).catch(() => undefined);
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function resolveDashboardDir(cwd: string): Promise<string> {
|
|
106
|
+
const projectRoot = await realpath(cwd);
|
|
107
|
+
return join(dirname(projectRoot), ".fpa-dashboard");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function dashboardBuildFingerprint(build: DashboardBuild): string {
|
|
111
|
+
assertBuild(build);
|
|
112
|
+
return sha256(stableJson({
|
|
113
|
+
title: build.title,
|
|
114
|
+
widgets: build.widgets,
|
|
115
|
+
source: build.source,
|
|
116
|
+
warnings: build.warnings,
|
|
117
|
+
}));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function publishDashboard(options: PublishDashboardOptions): Promise<PublishDashboardResult> {
|
|
121
|
+
assertBuild(options.build);
|
|
122
|
+
const dashboardDir = await resolveDashboardDir(options.cwd);
|
|
123
|
+
await ensureOwnedDirectory(dashboardDir, "Dashboard directory");
|
|
124
|
+
const datasetsDir = join(dashboardDir, "datasets");
|
|
125
|
+
await ensureOwnedDirectory(datasetsDir, "Dashboard datasets directory");
|
|
126
|
+
|
|
127
|
+
const publishedAt = options.publishedAt ?? new Date().toISOString();
|
|
128
|
+
const fingerprint = dashboardBuildFingerprint(options.build);
|
|
129
|
+
const datasets = options.build.widgets.map(contentAddressDataset);
|
|
130
|
+
for (const dataset of datasets) {
|
|
131
|
+
await atomicWrite(join(datasetsDir, dataset.filename), dataset.contents);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const receipt = {
|
|
135
|
+
kind: "fpa.dashboard.build",
|
|
136
|
+
schema_version: 1,
|
|
137
|
+
generation_id: fingerprint,
|
|
138
|
+
published_at: publishedAt,
|
|
139
|
+
source: options.build.source,
|
|
140
|
+
warnings: options.build.warnings,
|
|
141
|
+
datasets: datasets.map(({ logical, filename, sha256 }) => ({ logical, filename, sha256 })),
|
|
142
|
+
};
|
|
143
|
+
const receiptContents = `${JSON.stringify(receipt, null, 2)}\n`;
|
|
144
|
+
const receiptDigest = sha256(stableJson(receipt));
|
|
145
|
+
const receiptFilename = `build-receipt.${fingerprint.slice(0, 12)}.${receiptDigest.slice(0, 12)}.json`;
|
|
146
|
+
await atomicWrite(join(dashboardDir, receiptFilename), receiptContents);
|
|
147
|
+
// Compatibility pointer for older diagnostics. The manifest below points to
|
|
148
|
+
// the generation-specific receipt, so concurrent publishers cannot mix lineage.
|
|
149
|
+
await atomicWrite(join(dashboardDir, "build-receipt.json"), receiptContents);
|
|
150
|
+
|
|
151
|
+
const byLogical = new Map(datasets.map((dataset) => [dataset.logical, dataset.filename]));
|
|
152
|
+
const manifest = {
|
|
153
|
+
kind: "fpa.dashboard",
|
|
154
|
+
schemaVersion: 1,
|
|
155
|
+
title: options.build.title,
|
|
156
|
+
updatedAt: publishedAt,
|
|
157
|
+
generationId: fingerprint,
|
|
158
|
+
buildReceipt: receiptFilename,
|
|
159
|
+
widgets: options.build.widgets.map((widget) => ({
|
|
160
|
+
id: widget.id,
|
|
161
|
+
type: widget.type,
|
|
162
|
+
span: widget.span,
|
|
163
|
+
dataset: byLogical.get(widget.dataset),
|
|
164
|
+
})),
|
|
165
|
+
};
|
|
166
|
+
// This is the commit point: every referenced dataset and receipt is
|
|
167
|
+
// generation-specific, so concurrent publishers remain atomic without locks.
|
|
168
|
+
await atomicWrite(join(dashboardDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
169
|
+
return { dashboardDir, fingerprint, publishedDatasets: datasets.length };
|
|
170
|
+
}
|