@viccydev/pi-fpa 0.5.0 → 0.6.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.
@@ -0,0 +1,559 @@
1
+ import {
2
+ validateArtifact,
3
+ validateExecutionAgainstForecast,
4
+ type ApprovedCycleForecastInput,
5
+ type ExecutionReceiptInput,
6
+ type ScenarioMetric,
7
+ } from "../fpa-artifacts/contracts.ts";
8
+ import {
9
+ ACTUALS_DATA_AS_OF_UNAVAILABLE,
10
+ validateActualsSnapshot,
11
+ type DashboardActualsSnapshot,
12
+ } from "./source.ts";
13
+ import type { DashboardWidget, TableCell } from "./projector.ts";
14
+
15
+ export interface CycleOperatingProjectionInput {
16
+ scope_id: string;
17
+ cycle_id: string;
18
+ current_forecast: unknown;
19
+ current_forecast_role?: "original" | "eac" | "next_plan";
20
+ actuals: unknown;
21
+ execution?: unknown;
22
+ next_forecast?: unknown;
23
+ next_forecast_role?: "original" | "eac" | "next_plan";
24
+ }
25
+
26
+ interface ScenarioValues {
27
+ downside: number | null;
28
+ base: number | null;
29
+ upside: number | null;
30
+ unit: string;
31
+ }
32
+
33
+ export interface CycleOperatingProjection {
34
+ kind: "fpa.cycle-operating-projection";
35
+ schema_version: 1;
36
+ scope_id: string;
37
+ cycle_id: string;
38
+ data_as_of: string;
39
+ reporting_currency: string;
40
+ current_cycle: {
41
+ forecast_version: string;
42
+ target_period: ApprovedCycleForecastInput["target_period"];
43
+ forecast: { spend: ScenarioValues; revenue: ScenarioValues; roas: ScenarioValues };
44
+ actual_to_date: { spend: number | null; revenue: number | null; roas: number | null };
45
+ variance: {
46
+ status: "closed_comparable" | "not_comparable_partial_period" | "not_comparable_missing_actuals" | "not_comparable_unverified_snapshot" | "not_comparable_non_original_forecast";
47
+ revenue_delta: number | null;
48
+ revenue_delta_pct: number | null;
49
+ spend_delta: number | null;
50
+ spend_delta_pct: number | null;
51
+ };
52
+ execution: {
53
+ status: "not_recorded" | ExecutionReceiptInput["status"];
54
+ verification_status: "not_recorded" | ExecutionReceiptInput["verification_status"];
55
+ execution_mode: "not_recorded" | ExecutionReceiptInput["execution_mode"];
56
+ reconciliation_result: "not_recorded" | ExecutionReceiptInput["reconciliation"]["result"];
57
+ external_evidence_count: number;
58
+ };
59
+ };
60
+ next_cycle:
61
+ | { status: "unavailable"; reason: string }
62
+ | {
63
+ status: "ready" | "ready_with_limits";
64
+ forecast_version: string;
65
+ target_period: ApprovedCycleForecastInput["target_period"];
66
+ expected_spend: ScenarioValues;
67
+ expected_revenue: ScenarioValues;
68
+ expected_roas: ScenarioValues;
69
+ strategy: Array<{
70
+ app_id: string;
71
+ store: string;
72
+ channel_group: string;
73
+ action: ApprovedCycleForecastInput["approved_allocation"][number]["action"];
74
+ baseline_spend: number | null;
75
+ approved_spend: number;
76
+ spend_change: number | null;
77
+ spend_change_pct: number | null;
78
+ expected_revenue: number | null;
79
+ approval_id: string;
80
+ strategy_review_id: string;
81
+ stop_loss_roas_lt: number;
82
+ owner: string | null;
83
+ }>;
84
+ };
85
+ warnings: string[];
86
+ }
87
+
88
+ function identity(value: unknown, path: string): string {
89
+ if (typeof value !== "string" || value.trim() === "" || value.length > 256 || /[\u0000-\u001f\u007f]/.test(value)) {
90
+ throw new Error(`${path} must be a non-empty string of at most 256 characters without control characters.`);
91
+ }
92
+ return value;
93
+ }
94
+
95
+ function forecast(value: unknown, path: string): ApprovedCycleForecastInput {
96
+ const artifact = validateArtifact(value);
97
+ if (artifact.artifact_type !== "approved_cycle_forecast") throw new Error(`${path} must be an approved_cycle_forecast.`);
98
+ return artifact;
99
+ }
100
+
101
+ function execution(value: unknown, current: ApprovedCycleForecastInput): ExecutionReceiptInput {
102
+ const artifact = validateArtifact(value);
103
+ if (artifact.artifact_type !== "execution_receipt") throw new Error("execution must be an execution_receipt.");
104
+ validateExecutionAgainstForecast(artifact, current);
105
+ return artifact;
106
+ }
107
+
108
+ function scenario(metric: ScenarioMetric | undefined, path: string): ScenarioValues {
109
+ if (!metric) throw new Error(`${path} is required.`);
110
+ return {
111
+ downside: metric.downside,
112
+ base: metric.base,
113
+ upside: metric.upside,
114
+ unit: metric.unit,
115
+ };
116
+ }
117
+
118
+ function round(value: number): number {
119
+ return Math.round(value * 1_000_000) / 1_000_000;
120
+ }
121
+
122
+ function safeRatio(numerator: number | null, denominator: number | null): number | null {
123
+ if (numerator === null || denominator === null || denominator === 0) return null;
124
+ return round(numerator / denominator);
125
+ }
126
+
127
+ function periodFinalDate(forecast: ApprovedCycleForecastInput): string {
128
+ const lastInstant = new Date(Date.parse(forecast.target_period.end_exclusive) - 1);
129
+ const parts = new Intl.DateTimeFormat("en-CA", {
130
+ timeZone: forecast.target_period.timezone,
131
+ year: "numeric",
132
+ month: "2-digit",
133
+ day: "2-digit",
134
+ }).formatToParts(lastInstant);
135
+ const values = Object.fromEntries(parts.map((part) => [part.type, part.value]));
136
+ return `${values.year}-${values.month}-${values.day}`;
137
+ }
138
+
139
+ function periodStartDate(forecast: ApprovedCycleForecastInput): string {
140
+ const parts = new Intl.DateTimeFormat("en-CA", {
141
+ timeZone: forecast.target_period.timezone,
142
+ year: "numeric",
143
+ month: "2-digit",
144
+ day: "2-digit",
145
+ }).formatToParts(new Date(forecast.target_period.start_inclusive));
146
+ const values = Object.fromEntries(parts.map((part) => [part.type, part.value]));
147
+ return `${values.year}-${values.month}-${values.day}`;
148
+ }
149
+
150
+ function addDay(date: string): string {
151
+ const value = new Date(`${date}T00:00:00Z`);
152
+ value.setUTCDate(value.getUTCDate() + 1);
153
+ return value.toISOString().slice(0, 10);
154
+ }
155
+
156
+ function approximatelyEqual(left: number, right: number): boolean {
157
+ return Math.abs(left - right) <= Math.max(0.000001, Math.max(Math.abs(left), Math.abs(right)) * 0.000001);
158
+ }
159
+
160
+ function actualsReconcile(actuals: DashboardActualsSnapshot, current: ApprovedCycleForecastInput): boolean {
161
+ if (actuals.snapshot_evidence.consistency !== "single_statement"
162
+ || !actuals.snapshot_evidence.snapshot_id
163
+ || !actuals.snapshot_evidence.available_at
164
+ || !actuals.snapshot_evidence.source_close_signal_id
165
+ || !actuals.snapshot_evidence.source_closed_through
166
+ || !actuals.snapshot_evidence.source_close_emitted_at
167
+ || !actuals.snapshot_evidence.source_close_signal_sha256
168
+ || !actuals.snapshot_evidence.period_complete
169
+ || !actuals.snapshot_evidence.reconciled) return false;
170
+ const start = periodStartDate(current);
171
+ const end = periodFinalDate(current);
172
+ if (actuals.coverage.date_min !== start || actuals.coverage.date_max !== end || actuals.coverage.source_rows <= 0) return false;
173
+ const byDate = new Map(actuals.daily.map((point) => [point.date, point]));
174
+ let dailySpend = 0;
175
+ let dailyRevenue = 0;
176
+ for (let date = start; date <= end; date = addDay(date)) {
177
+ const point = byDate.get(date);
178
+ if (!point || point.spend === null || point.revenue === null) return false;
179
+ dailySpend += point.spend;
180
+ dailyRevenue += point.revenue;
181
+ }
182
+ if (byDate.size !== actuals.daily.length || actuals.current.spend === null || actuals.current.revenue === null) return false;
183
+ const approvedKeys = new Set(current.approved_allocation.map((item) => `${item.app_id}\u0000${item.store}\u0000${item.channel_group}`));
184
+ const approvedSlices = actuals.slices.filter((item) => approvedKeys.has(`${item.app_id}\u0000${item.store}\u0000${item.channel_group}`));
185
+ const sliceSpend = approvedSlices.reduce<number | null>((sum, item) => sum === null || item.spend === null ? null : sum + item.spend, 0);
186
+ const sliceRevenue = approvedSlices.reduce<number | null>((sum, item) => sum === null || item.revenue === null ? null : sum + item.revenue, 0);
187
+ return sliceSpend !== null && sliceRevenue !== null
188
+ && approximatelyEqual(dailySpend, actuals.current.spend)
189
+ && approximatelyEqual(dailyRevenue, actuals.current.revenue)
190
+ && approximatelyEqual(sliceSpend, actuals.current.spend)
191
+ && approximatelyEqual(sliceRevenue, actuals.current.revenue);
192
+ }
193
+
194
+ function assertActualsMatchForecast(actuals: DashboardActualsSnapshot, current: ApprovedCycleForecastInput): void {
195
+ if (actuals.reporting_currency !== current.reporting_currency) throw new Error("actuals reporting_currency does not match current_forecast.");
196
+ if (actuals.period.start_inclusive !== current.target_period.start_inclusive
197
+ || actuals.period.end_exclusive !== current.target_period.end_exclusive
198
+ || actuals.period.timezone !== current.target_period.timezone) {
199
+ throw new Error("actuals period does not match current_forecast target_period.");
200
+ }
201
+ }
202
+
203
+ export function projectCycleOperatingProjection(input: CycleOperatingProjectionInput): CycleOperatingProjection {
204
+ const scopeId = identity(input.scope_id, "scope_id");
205
+ const cycleId = identity(input.cycle_id, "cycle_id");
206
+ const current = forecast(input.current_forecast, "current_forecast");
207
+ const actuals = validateActualsSnapshot(input.actuals);
208
+ assertActualsMatchForecast(actuals, current);
209
+ const receipt = input.execution === undefined ? undefined : execution(input.execution, current);
210
+ const next = input.next_forecast === undefined ? undefined : forecast(input.next_forecast, "next_forecast");
211
+ if (next && (Date.parse(next.target_period.start_inclusive) !== Date.parse(current.target_period.end_exclusive)
212
+ || next.target_period.timezone !== current.target_period.timezone)) {
213
+ throw new Error("next_forecast target period must be the exact successor of the current cycle in the same timezone.");
214
+ }
215
+ if (next && next.reporting_currency !== current.reporting_currency) throw new Error("next_forecast reporting_currency must match current_forecast.");
216
+ if (next && (next.status !== "complete" || !next.approval_conditions_satisfied)) {
217
+ throw new Error("next_forecast must be complete with approval conditions satisfied.");
218
+ }
219
+
220
+ const forecastSpend = scenario(current.consolidated_forecast.spend, "current_forecast.consolidated_forecast.spend");
221
+ const forecastRevenue = scenario(current.consolidated_forecast.revenue, "current_forecast.consolidated_forecast.revenue");
222
+ const forecastRoas = scenario(current.consolidated_forecast.roas, "current_forecast.consolidated_forecast.roas");
223
+ const actualSpend = actuals.current.spend;
224
+ const actualRevenue = actuals.current.revenue;
225
+ const reachesPeriodEnd = actuals.data_as_of !== ACTUALS_DATA_AS_OF_UNAVAILABLE
226
+ && actuals.coverage.date_max !== null
227
+ && actuals.coverage.date_max >= periodFinalDate(current);
228
+ const originalForecast = input.current_forecast_role === "original"
229
+ && Date.parse(current.frozen_at) <= Date.parse(current.target_period.start_inclusive);
230
+ const reconciledSnapshot = actualsReconcile(actuals, current);
231
+ const actualsMissing = actualSpend === null || actualRevenue === null;
232
+ const varianceStatus = actualsMissing
233
+ ? "not_comparable_missing_actuals" as const
234
+ : !reachesPeriodEnd ? "not_comparable_partial_period" as const
235
+ : !reconciledSnapshot ? "not_comparable_unverified_snapshot" as const
236
+ : !originalForecast ? "not_comparable_non_original_forecast" as const
237
+ : "closed_comparable" as const;
238
+ const revenueDelta = varianceStatus === "closed_comparable" && forecastRevenue.base !== null ? round(actualRevenue - forecastRevenue.base) : null;
239
+ const spendDelta = varianceStatus === "closed_comparable" && forecastSpend.base !== null ? round(actualSpend - forecastSpend.base) : null;
240
+ const warnings: string[] = [];
241
+ if (varianceStatus === "not_comparable_partial_period") warnings.push("Current Actuals are partial-period; full-cycle forecast variance is intentionally unavailable.");
242
+ if (varianceStatus === "not_comparable_missing_actuals") warnings.push("Current Actuals are incomplete; forecast variance is unavailable.");
243
+ if (varianceStatus === "not_comparable_unverified_snapshot") warnings.push("Actuals have not supplied a complete, reconciled single-statement snapshot; closed-cycle variance is unavailable.");
244
+ if (varianceStatus === "not_comparable_non_original_forecast") warnings.push("The linked forecast is not a pre-period original forecast; it cannot be used as the closed-cycle accuracy baseline.");
245
+ if (!receipt) warnings.push("No execution receipt is linked to the current forecast.");
246
+ else if (receipt.verification_status !== "verified") warnings.push(`Execution is ${receipt.verification_status}, not externally verified.`);
247
+ if (!next) warnings.push("No approved next-cycle forecast is available; next-cycle strategy and expected revenue are unavailable.");
248
+ const nextSpend = next ? scenario(next.consolidated_forecast.spend, "next_forecast.consolidated_forecast.spend") : null;
249
+ const nextRevenue = next ? scenario(next.consolidated_forecast.revenue, "next_forecast.consolidated_forecast.revenue") : null;
250
+ const nextRoas = next ? scenario(next.consolidated_forecast.roas, "next_forecast.consolidated_forecast.roas") : null;
251
+ const nextHasLimits = !!next && (nextSpend?.base === null || nextRevenue?.base === null || nextRoas?.base === null
252
+ || next.approved_allocation.some((item) => !item.owner));
253
+ if (nextHasLimits) warnings.push("The next-cycle forecast is approved with unsupported base metrics; unavailable expectations remain null.");
254
+ if (next?.approved_allocation.some((item) => !item.owner)) warnings.push("At least one next-cycle allocation has no accountable owner; strategy readiness is limited.");
255
+
256
+ return {
257
+ kind: "fpa.cycle-operating-projection",
258
+ schema_version: 1,
259
+ scope_id: scopeId,
260
+ cycle_id: cycleId,
261
+ data_as_of: actuals.data_as_of,
262
+ reporting_currency: current.reporting_currency,
263
+ current_cycle: {
264
+ forecast_version: current.forecast_version,
265
+ target_period: current.target_period,
266
+ forecast: { spend: forecastSpend, revenue: forecastRevenue, roas: forecastRoas },
267
+ actual_to_date: { spend: actualSpend, revenue: actualRevenue, roas: safeRatio(actualRevenue, actualSpend) },
268
+ variance: {
269
+ status: varianceStatus,
270
+ revenue_delta: revenueDelta,
271
+ revenue_delta_pct: safeRatio(revenueDelta, forecastRevenue.base),
272
+ spend_delta: spendDelta,
273
+ spend_delta_pct: safeRatio(spendDelta, forecastSpend.base),
274
+ },
275
+ execution: receipt ? {
276
+ status: receipt.status,
277
+ verification_status: receipt.verification_status,
278
+ execution_mode: receipt.execution_mode,
279
+ reconciliation_result: receipt.reconciliation.result,
280
+ external_evidence_count: receipt.external_receipt_ids.length,
281
+ } : { status: "not_recorded", verification_status: "not_recorded", execution_mode: "not_recorded", reconciliation_result: "not_recorded", external_evidence_count: 0 },
282
+ },
283
+ next_cycle: next ? {
284
+ status: nextHasLimits ? "ready_with_limits" : "ready",
285
+ forecast_version: next.forecast_version,
286
+ target_period: next.target_period,
287
+ expected_spend: nextSpend as ScenarioValues,
288
+ expected_revenue: nextRevenue as ScenarioValues,
289
+ expected_roas: nextRoas as ScenarioValues,
290
+ strategy: next.approved_allocation.map((item) => {
291
+ const slice = next.forecast_by_slice.find((candidate) => candidate.app_id === item.app_id && candidate.store === item.store && candidate.channel_group === item.channel_group);
292
+ const spendChange = item.baseline_spend === null ? null : round(item.approved_spend - item.baseline_spend);
293
+ return {
294
+ app_id: item.app_id,
295
+ store: item.store,
296
+ channel_group: item.channel_group,
297
+ action: item.action,
298
+ baseline_spend: item.baseline_spend,
299
+ approved_spend: item.approved_spend,
300
+ spend_change: spendChange,
301
+ spend_change_pct: safeRatio(spendChange, item.baseline_spend),
302
+ expected_revenue: slice?.metrics.revenue?.base ?? null,
303
+ approval_id: next.human_approval_id,
304
+ strategy_review_id: next.strategy_review_id,
305
+ stop_loss_roas_lt: next.calibration_policy.stop_loss_roas_lt,
306
+ owner: item.owner ?? null,
307
+ };
308
+ }),
309
+ } : {
310
+ status: "unavailable",
311
+ reason: "No approved next-cycle forecast is linked.",
312
+ },
313
+ warnings,
314
+ };
315
+ }
316
+
317
+ function formatCurrency(value: number | null, currency: string, locale: string): string | null {
318
+ if (value === null) return null;
319
+ return new Intl.NumberFormat(locale, {
320
+ style: "currency",
321
+ currency,
322
+ currencyDisplay: "narrowSymbol",
323
+ minimumFractionDigits: 0,
324
+ maximumFractionDigits: 2,
325
+ }).format(value);
326
+ }
327
+
328
+ function formatPercent(value: number | null, locale: string): string | null {
329
+ if (value === null) return null;
330
+ return new Intl.NumberFormat(locale, { style: "percent", minimumFractionDigits: 1, maximumFractionDigits: 1, signDisplay: "always" }).format(value);
331
+ }
332
+
333
+ function toned(value: string | null, tone?: "positive" | "negative" | "warning" | "neutral"): TableCell {
334
+ return { value, ...(tone ? { tone } : {}) };
335
+ }
336
+
337
+ export function projectCycleDecisionWidgets(
338
+ projection: CycleOperatingProjection,
339
+ locale = "zh-CN",
340
+ ): DashboardWidget[] {
341
+ const zh = locale.toLowerCase().startsWith("zh");
342
+ const variance = projection.current_cycle.variance;
343
+ const varianceTone = variance.revenue_delta === null
344
+ ? "neutral" as const
345
+ : variance.revenue_delta >= 0 ? "positive" as const : "negative" as const;
346
+ const varianceWidget: DashboardWidget = {
347
+ id: "closed-cycle-revenue-variance",
348
+ type: "stat",
349
+ span: "quarter",
350
+ dataset: "closed-cycle-revenue-variance.json",
351
+ data: {
352
+ label: zh ? "上周期收入预测差异" : "Closed-cycle revenue variance",
353
+ value: formatCurrency(variance.revenue_delta, projection.reporting_currency, locale),
354
+ ...(variance.revenue_delta === null ? {
355
+ missingReason: variance.status === "not_comparable_partial_period"
356
+ ? zh ? "周期尚未结束,不能拿累计实际值与整周期预测比较" : "The period is still open; partial Actuals are not compared with a full-cycle forecast."
357
+ : variance.status === "not_comparable_non_original_forecast"
358
+ ? zh ? "当前版本不是周期开始前冻结的原始预测" : "The linked version is not the pre-period original forecast."
359
+ : variance.status === "not_comparable_unverified_snapshot"
360
+ ? zh ? "Actual 尚无完整且可勾稽的统一快照" : "Actuals do not yet have a complete reconciled snapshot."
361
+ : zh ? "实际收入数据不完整" : "Actual revenue is incomplete.",
362
+ } : {
363
+ delta: {
364
+ direction: variance.revenue_delta > 0 ? "up" as const : variance.revenue_delta < 0 ? "down" as const : "flat" as const,
365
+ label: formatPercent(variance.revenue_delta_pct, locale) ?? "",
366
+ sentiment: varianceTone,
367
+ },
368
+ }),
369
+ description: zh ? "仅在周期关闭且 Actual 完整时显示" : "Shown only when the cycle is closed and Actuals are complete.",
370
+ },
371
+ };
372
+ const execution = projection.current_cycle.execution;
373
+ const executionWidget: DashboardWidget = {
374
+ id: "current-cycle-execution-evidence",
375
+ type: "stat",
376
+ span: "quarter",
377
+ dataset: "current-cycle-execution-evidence.json",
378
+ data: execution.verification_status === "verified" ? {
379
+ label: zh ? "本周期执行证据" : "Current-cycle execution evidence",
380
+ value: zh ? "外部已核验" : "Externally verified",
381
+ description: zh
382
+ ? `${execution.external_evidence_count} 条外部回执 · 勾稽 ${execution.reconciliation_result}`
383
+ : `${execution.external_evidence_count} external receipt(s) · reconciliation ${execution.reconciliation_result}`,
384
+ } : {
385
+ label: zh ? "本周期执行证据" : "Current-cycle execution evidence",
386
+ value: null,
387
+ missingReason: execution.verification_status === "not_recorded"
388
+ ? zh ? "尚无执行回执" : "No execution receipt is linked."
389
+ : execution.verification_status === "reported"
390
+ ? zh ? "仅人工报告,尚无外部只读核验证据" : "Reported only; no external read-only verification evidence is linked."
391
+ : zh ? "执行核验失败" : "Execution verification failed.",
392
+ description: zh ? "观察到 Actual 花费不等于广告账户变更已核验" : "Observed Actual spend is not proof that an ad-account mutation was verified.",
393
+ },
394
+ };
395
+
396
+ if (projection.next_cycle.status === "unavailable") {
397
+ return [{
398
+ id: "next-cycle-readiness",
399
+ type: "stat",
400
+ span: "quarter",
401
+ dataset: "next-cycle-readiness.json",
402
+ data: {
403
+ label: zh ? "下一周期投放策略" : "Next-cycle strategy",
404
+ value: null,
405
+ missingReason: zh ? "尚无已批准的下一周期预测" : "No approved next-cycle forecast is available.",
406
+ description: zh ? "不会用当前周期策略冒充下一周期建议" : "The current-cycle allocation is never relabeled as a next-cycle recommendation.",
407
+ },
408
+ }, executionWidget, varianceWidget];
409
+ }
410
+
411
+ const next = projection.next_cycle;
412
+ const nextSpend = next.expected_spend.base;
413
+ const nextRevenue = next.expected_revenue.base;
414
+ const channelRows = [...next.strategy.reduce((groups, item) => {
415
+ const current = groups.get(item.channel_group) ?? { baseline: 0, baselineComplete: true, spend: 0, revenue: 0, revenueComplete: true, actions: new Set<string>(), owners: new Set<string>(), stopLoss: item.stop_loss_roas_lt };
416
+ if (item.baseline_spend === null) current.baselineComplete = false;
417
+ else current.baseline += item.baseline_spend;
418
+ current.spend += item.approved_spend;
419
+ if (item.expected_revenue === null) current.revenueComplete = false;
420
+ else current.revenue += item.expected_revenue;
421
+ current.actions.add(item.action);
422
+ if (item.owner) current.owners.add(item.owner);
423
+ current.stopLoss = Math.max(current.stopLoss, item.stop_loss_roas_lt);
424
+ groups.set(item.channel_group, current);
425
+ return groups;
426
+ }, new Map<string, { baseline: number; baselineComplete: boolean; spend: number; revenue: number; revenueComplete: boolean; actions: Set<string>; owners: Set<string>; stopLoss: number }>()).entries()]
427
+ .map(([channel, values]) => ({ channel, ...values, expectedRoas: values.revenueComplete ? safeRatio(values.revenue, values.spend) : null }))
428
+ .sort((left, right) => right.spend - left.spend);
429
+ const riskUnit = next.strategy
430
+ .map((item) => ({ item, expectedRoas: safeRatio(item.expected_revenue, item.approved_spend) }))
431
+ .filter((candidate) => candidate.expectedRoas !== null)
432
+ .sort((left, right) => ((left.expectedRoas as number) - left.item.stop_loss_roas_lt) - ((right.expectedRoas as number) - right.item.stop_loss_roas_lt))[0];
433
+ const strategyWidget: DashboardWidget = {
434
+ id: "next-cycle-strategy",
435
+ type: "table",
436
+ span: "full",
437
+ dataset: "next-cycle-strategy.json",
438
+ data: {
439
+ label: zh ? "下一周期投放动作" : "Next-cycle allocation actions",
440
+ description: zh ? "基线→批准预算→收入影响均来自同一不可变 Forecast;缺失责任人会降低策略就绪状态。" : "Baseline, approved budget and revenue impact share one immutable Forecast; missing ownership limits readiness.",
441
+ columns: [
442
+ { key: "unit", label: zh ? "投放单元" : "Unit" },
443
+ { key: "action", label: zh ? "动作" : "Action" },
444
+ { key: "baseline", label: zh ? "当前基线" : "Baseline", align: "right" },
445
+ { key: "spend", label: zh ? "批准预算" : "Approved spend", align: "right" },
446
+ { key: "change", label: zh ? "预算变化" : "Budget change", align: "right" },
447
+ { key: "revenue", label: zh ? "预期收入" : "Expected revenue", align: "right" },
448
+ { key: "guardrail", label: zh ? "止损/责任" : "Guardrail / owner" },
449
+ ],
450
+ rows: next.strategy.map((item) => ({
451
+ unit: `${item.app_id} · ${item.channel_group} · ${item.store}`,
452
+ action: toned(zh ? ({ stop: "停止", decrease: "减量", hold: "维持", increase: "加量", explore: "探索" } as const)[item.action] : item.action, "neutral"),
453
+ baseline: formatCurrency(item.baseline_spend, projection.reporting_currency, locale),
454
+ spend: formatCurrency(item.approved_spend, projection.reporting_currency, locale),
455
+ change: item.spend_change === null
456
+ ? null
457
+ : `${formatCurrency(item.spend_change, projection.reporting_currency, locale)} (${formatPercent(item.spend_change_pct, locale)})`,
458
+ revenue: formatCurrency(item.expected_revenue, projection.reporting_currency, locale),
459
+ guardrail: zh
460
+ ? `ROAS < ${item.stop_loss_roas_lt} 止损 · ${item.owner ? `责任人 ${item.owner}` : "责任人未记录"} · 审批 ${item.approval_id} · 复核 ${item.strategy_review_id}`
461
+ : `Stop if ROAS < ${item.stop_loss_roas_lt} · ${item.owner ? `owner ${item.owner}` : "owner not captured"} · approval ${item.approval_id} · review ${item.strategy_review_id}`,
462
+ })),
463
+ },
464
+ };
465
+ const channelSummaryWidget: DashboardWidget = {
466
+ id: "next-cycle-channel-summary",
467
+ type: "table",
468
+ span: "full",
469
+ dataset: "next-cycle-channel-summary.json",
470
+ data: {
471
+ label: zh ? "下一周期渠道汇总" : "Next-cycle channel summary",
472
+ description: zh ? "先按渠道判断预算、收入、ROAS 与责任,再下钻到投放单元。" : "Review budget, revenue, ROAS and ownership by channel before drilling into units.",
473
+ columns: [
474
+ { key: "channel", label: zh ? "渠道" : "Channel" },
475
+ { key: "action", label: zh ? "动作组合" : "Actions" },
476
+ { key: "baseline", label: zh ? "当前基线" : "Baseline", align: "right" },
477
+ { key: "spend", label: zh ? "批准预算" : "Approved spend", align: "right" },
478
+ { key: "revenue", label: zh ? "预期收入" : "Expected revenue", align: "right" },
479
+ { key: "roas", label: "ROAS", align: "right" },
480
+ { key: "owner", label: zh ? "责任人" : "Owner" },
481
+ ],
482
+ rows: channelRows.map((row) => ({
483
+ channel: row.channel,
484
+ action: [...row.actions].map((action) => zh ? ({ stop: "停止", decrease: "减量", hold: "维持", increase: "加量", explore: "探索" } as Record<string, string>)[action] ?? action : action).join(" / "),
485
+ baseline: row.baselineComplete ? formatCurrency(row.baseline, projection.reporting_currency, locale) : null,
486
+ spend: formatCurrency(row.spend, projection.reporting_currency, locale),
487
+ revenue: row.revenueComplete ? formatCurrency(row.revenue, projection.reporting_currency, locale) : null,
488
+ roas: row.expectedRoas === null ? null : toned(row.expectedRoas.toFixed(2), row.expectedRoas < row.stopLoss ? "warning" : "neutral"),
489
+ owner: row.owners.size > 0 ? [...row.owners].join(" / ") : null,
490
+ })),
491
+ },
492
+ };
493
+ return [
494
+ {
495
+ id: "next-cycle-readiness",
496
+ type: "stat",
497
+ span: "quarter",
498
+ dataset: "next-cycle-readiness.json",
499
+ data: {
500
+ label: zh ? "下一周期投放策略" : "Next-cycle strategy",
501
+ value: next.status === "ready_with_limits" ? (zh ? "已批准·有限" : "Ready with limits") : (zh ? "已批准" : "Ready"),
502
+ description: `${next.forecast_version} · ${next.target_period.start_inclusive} → ${next.target_period.end_exclusive}`,
503
+ },
504
+ },
505
+ {
506
+ id: "next-cycle-expected-revenue",
507
+ type: "stat",
508
+ span: "quarter",
509
+ dataset: "next-cycle-expected-revenue.json",
510
+ data: {
511
+ label: zh ? "下一周期预期收入" : "Next-cycle expected revenue",
512
+ value: formatCurrency(nextRevenue, projection.reporting_currency, locale),
513
+ ...(nextRevenue === null ? { missingReason: zh ? "该批准预测未提供可支持的收入基准情景" : "The approved forecast does not support a base revenue expectation." } : {}),
514
+ footnote: `${zh ? "下行" : "Downside"} ${formatCurrency(next.expected_revenue.downside, projection.reporting_currency, locale) ?? "—"} · ${zh ? "上行" : "Upside"} ${formatCurrency(next.expected_revenue.upside, projection.reporting_currency, locale) ?? "—"}`,
515
+ },
516
+ },
517
+ {
518
+ id: "next-cycle-approved-spend",
519
+ type: "stat",
520
+ span: "quarter",
521
+ dataset: "next-cycle-approved-spend.json",
522
+ data: {
523
+ label: zh ? "下一周期批准预算" : "Next-cycle approved spend",
524
+ value: formatCurrency(nextSpend, projection.reporting_currency, locale),
525
+ description: zh ? "与预期收入使用同一批准预测版本" : "Uses the same approved forecast version as expected revenue.",
526
+ },
527
+ },
528
+ {
529
+ id: "next-cycle-expected-roas",
530
+ type: "stat",
531
+ span: "quarter",
532
+ dataset: "next-cycle-expected-roas.json",
533
+ data: {
534
+ label: zh ? "下一周期预期 ROAS" : "Next-cycle expected ROAS",
535
+ value: next.expected_roas.base === null ? null : next.expected_roas.base.toFixed(2),
536
+ ...(next.expected_roas.base === null ? { missingReason: zh ? "该批准预测未提供可支持的 ROAS 基准情景" : "The approved forecast does not support a base ROAS expectation." } : {}),
537
+ },
538
+ },
539
+ {
540
+ id: "next-cycle-max-guardrail-risk",
541
+ type: "stat",
542
+ span: "quarter",
543
+ dataset: "next-cycle-max-guardrail-risk.json",
544
+ data: riskUnit ? {
545
+ label: zh ? "离止损线最近" : "Closest to stop-loss",
546
+ value: `${riskUnit.item.app_id} · ${riskUnit.item.channel_group} · ${riskUnit.item.store}`,
547
+ description: `${zh ? "预期 ROAS" : "Expected ROAS"} ${(riskUnit.expectedRoas as number).toFixed(2)} · ${zh ? "止损线" : "stop-loss"} ${riskUnit.item.stop_loss_roas_lt.toFixed(2)} · ${zh ? "余量" : "margin"} ${((riskUnit.expectedRoas as number) - riskUnit.item.stop_loss_roas_lt).toFixed(2)} · ${riskUnit.item.owner ?? (zh ? "责任人未记录" : "owner not captured")}`,
548
+ } : {
549
+ label: zh ? "离止损线最近" : "Closest to stop-loss",
550
+ value: null,
551
+ missingReason: zh ? "投放单元缺少可比较的预期收入" : "Units lack comparable expected revenue.",
552
+ },
553
+ },
554
+ channelSummaryWidget,
555
+ strategyWidget,
556
+ executionWidget,
557
+ varianceWidget,
558
+ ];
559
+ }