@viccydev/pi-fpa 0.8.1 → 0.9.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 +2 -2
- package/extensions/fpa-artifacts/compose.ts +156 -42
- package/extensions/fpa-artifacts/contracts.ts +202 -2
- package/extensions/fpa-artifacts/forecast-quality.ts +138 -0
- package/extensions/fpa-artifacts/index.ts +2 -1
- package/extensions/fpa-dashboard/coordinator.ts +2 -2
- package/extensions/fpa-dashboard/cycle-operating-projection.ts +5 -4
- package/extensions/fpa-dashboard/forward-outlook.ts +5 -2
- package/extensions/fpa-dashboard/index.ts +2 -2
- package/extensions/fpa-dashboard/projector.ts +58 -1
- package/extensions/fpa-dashboard/service.ts +13 -2
- package/extensions/fpa-dashboard/stage-projector.ts +60 -2
- package/graphs/fpa-forecast-freeze.json +17 -5
- package/package.json +1 -1
- package/skills/fpa-diagnose-actuals/references/artifact-contract.md +2 -0
- package/skills/fpa-forecast-approved-strategy/SKILL.md +12 -0
- package/skills/fpa-forecast-approved-strategy/references/artifact-contract.md +26 -1
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.
|
|
162
|
+
pi install git:github.com/linyqh/pi-fpa@v0.9.0
|
|
163
163
|
```
|
|
164
164
|
|
|
165
165
|
## 发布到 npm
|
|
166
166
|
|
|
167
|
-
发布动作由 GitHub Release 触发。Release 标签必须严格使用 `v<package.json version>`,例如版本 `0.
|
|
167
|
+
发布动作由 GitHub Release 触发。Release 标签必须严格使用 `v<package.json version>`,例如版本 `0.9.0` 对应 `v0.9.0`。工作流会检出该标签,执行 `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
|
|
|
@@ -6,10 +6,16 @@ import {
|
|
|
6
6
|
type AllocationAction,
|
|
7
7
|
type ApprovedCycleForecastInput,
|
|
8
8
|
type ForecastAllocation,
|
|
9
|
+
type ForecastConclusion,
|
|
9
10
|
type ForecastSlice,
|
|
10
11
|
type Period,
|
|
11
12
|
type ScenarioMetric,
|
|
12
13
|
} from "./contracts.ts";
|
|
14
|
+
import {
|
|
15
|
+
assessForecastQuality,
|
|
16
|
+
type ForecastDimensionSnapshot,
|
|
17
|
+
type ForecastQualityAssessment,
|
|
18
|
+
} from "./forecast-quality.ts";
|
|
13
19
|
|
|
14
20
|
// ============================================================================
|
|
15
21
|
// Composing an approved forecast from a compact plan.
|
|
@@ -36,11 +42,7 @@ import {
|
|
|
36
42
|
// ============================================================================
|
|
37
43
|
|
|
38
44
|
/** Distinct dimension values a plan's slice keys must be drawn from. */
|
|
39
|
-
export interface UaDimensionValues {
|
|
40
|
-
app_code: Set<string>;
|
|
41
|
-
platform: Set<string>;
|
|
42
|
-
media_source: Set<string>;
|
|
43
|
-
}
|
|
45
|
+
export interface UaDimensionValues extends ForecastDimensionSnapshot {}
|
|
44
46
|
|
|
45
47
|
export interface PlanSliceRoas {
|
|
46
48
|
downside: number | null;
|
|
@@ -172,7 +174,10 @@ function sumScenario(values: Array<number | null>): number | null {
|
|
|
172
174
|
export interface ComposeDiagnostics {
|
|
173
175
|
slice_count: number;
|
|
174
176
|
stopped_slice_count: number;
|
|
177
|
+
limited_slice_count: number;
|
|
175
178
|
total_approved_spend: number;
|
|
179
|
+
calculable_approved_spend: number;
|
|
180
|
+
calculable_spend_pct: number;
|
|
176
181
|
validated_dimensions: boolean;
|
|
177
182
|
portfolio_placeholder_app_id: string | null;
|
|
178
183
|
}
|
|
@@ -192,21 +197,78 @@ export interface ComposeResult {
|
|
|
192
197
|
*/
|
|
193
198
|
export async function loadUaDimensionValues(signal?: AbortSignal): Promise<UaDimensionValues> {
|
|
194
199
|
const result = await runStructuredQuery(
|
|
195
|
-
{ dataset: "ua_spend", metrics: ["spend"], dimensions: ["app_code", "platform", "media_source"], limit:
|
|
200
|
+
{ dataset: "ua_spend", metrics: ["spend"], dimensions: ["app_code", "platform", "media_source"], limit: 1000 },
|
|
196
201
|
signal,
|
|
197
202
|
);
|
|
198
|
-
const values: UaDimensionValues = { app_code: new Set(), platform: new Set(), media_source: new Set() };
|
|
203
|
+
const values: UaDimensionValues = { app_code: new Set(), platform: new Set(), media_source: new Set(), tuples: new Set() };
|
|
199
204
|
for (const row of result.rows) {
|
|
200
205
|
if (row.app_code != null) values.app_code.add(String(row.app_code));
|
|
201
206
|
if (row.platform != null) values.platform.add(String(row.platform));
|
|
202
207
|
if (row.media_source != null) values.media_source.add(String(row.media_source));
|
|
208
|
+
if (row.app_code != null && row.platform != null && row.media_source != null) {
|
|
209
|
+
values.tuples?.add(sliceKey({
|
|
210
|
+
app_id: String(row.app_code),
|
|
211
|
+
store: String(row.platform),
|
|
212
|
+
channel_group: String(row.media_source),
|
|
213
|
+
}));
|
|
214
|
+
}
|
|
203
215
|
}
|
|
204
216
|
return values;
|
|
205
217
|
}
|
|
206
218
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
219
|
+
/**
|
|
220
|
+
* Read only the candidate tuples named by the plan. This avoids treating the
|
|
221
|
+
* structured query's 1000-row safety cap as a complete dimension catalog.
|
|
222
|
+
*/
|
|
223
|
+
async function loadUaDimensionValuesForSlices(slices: PlanSlice[], signal?: AbortSignal): Promise<UaDimensionValues> {
|
|
224
|
+
const appIds = new Set(slices.map((slice) => slice.app_id));
|
|
225
|
+
const collapsedAppId = appIds.size === 1 ? [...appIds][0] : null;
|
|
226
|
+
let portfolioMode = false;
|
|
227
|
+
if (collapsedAppId !== null) {
|
|
228
|
+
const appProbe = await runStructuredQuery({
|
|
229
|
+
dataset: "ua_spend",
|
|
230
|
+
metrics: ["spend"],
|
|
231
|
+
dimensions: ["app_code"],
|
|
232
|
+
filters: { app_code: collapsedAppId },
|
|
233
|
+
limit: 1,
|
|
234
|
+
}, signal);
|
|
235
|
+
portfolioMode = appProbe.rows.length === 0;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const uniqueSlices = [...new Map(slices.map((slice) => [sliceKey(slice), slice])).values()];
|
|
239
|
+
const result = portfolioMode
|
|
240
|
+
? await runStructuredQuery({
|
|
241
|
+
dataset: "ua_spend",
|
|
242
|
+
metrics: ["spend"],
|
|
243
|
+
dimensions: ["platform", "media_source"],
|
|
244
|
+
exactUaChannelScope: uniqueSlices.map((slice) => ({ platform: slice.store, media_source: slice.channel_group })),
|
|
245
|
+
limit: uniqueSlices.length,
|
|
246
|
+
}, signal)
|
|
247
|
+
: await runStructuredQuery({
|
|
248
|
+
dataset: "ua_spend",
|
|
249
|
+
metrics: ["spend"],
|
|
250
|
+
dimensions: ["app_code", "platform", "media_source"],
|
|
251
|
+
exactUaScope: uniqueSlices.map((slice) => ({
|
|
252
|
+
app_code: slice.app_id,
|
|
253
|
+
platform: slice.store,
|
|
254
|
+
media_source: slice.channel_group,
|
|
255
|
+
})),
|
|
256
|
+
limit: uniqueSlices.length,
|
|
257
|
+
}, signal);
|
|
258
|
+
const values: UaDimensionValues = { app_code: new Set(), platform: new Set(), media_source: new Set(), tuples: new Set() };
|
|
259
|
+
for (const row of result.rows) {
|
|
260
|
+
if (row.app_code != null) values.app_code.add(String(row.app_code));
|
|
261
|
+
if (row.platform != null) values.platform.add(String(row.platform));
|
|
262
|
+
if (row.media_source != null) values.media_source.add(String(row.media_source));
|
|
263
|
+
if (row.platform != null && row.media_source != null) {
|
|
264
|
+
values.tuples?.add(sliceKey({
|
|
265
|
+
app_id: row.app_code == null ? "" : String(row.app_code),
|
|
266
|
+
store: String(row.platform),
|
|
267
|
+
channel_group: String(row.media_source),
|
|
268
|
+
}));
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return values;
|
|
210
272
|
}
|
|
211
273
|
|
|
212
274
|
/**
|
|
@@ -218,33 +280,8 @@ function sample(values: Set<string>, limit = 8): string {
|
|
|
218
280
|
* test the dashboard applies, so the two agree on what "portfolio mode" means.
|
|
219
281
|
* Store and channel have no such mode — a value the mart never uses is an error.
|
|
220
282
|
*/
|
|
221
|
-
export function validateSliceKeys(slices: PlanSlice[], values: UaDimensionValues):
|
|
222
|
-
|
|
223
|
-
const collapsed = appIds.size === 1 ? [...appIds][0] : null;
|
|
224
|
-
const portfolioAppId = collapsed !== null && !values.app_code.has(collapsed) ? collapsed : null;
|
|
225
|
-
|
|
226
|
-
const problems: string[] = [];
|
|
227
|
-
const badStores = [...new Set(slices.map((slice) => slice.store).filter((store) => !values.platform.has(store)))];
|
|
228
|
-
if (badStores.length > 0) {
|
|
229
|
-
problems.push(`store ${badStores.map((value) => JSON.stringify(value)).join(", ")} is not a ua_spend.platform value. Valid values: ${sample(values.platform)}.`);
|
|
230
|
-
}
|
|
231
|
-
const badChannels = [...new Set(slices.map((slice) => slice.channel_group).filter((channel) => !values.media_source.has(channel)))];
|
|
232
|
-
if (badChannels.length > 0) {
|
|
233
|
-
problems.push(`channel_group ${badChannels.map((value) => JSON.stringify(value)).join(", ")} is not a ua_spend.media_source value. Valid values: ${sample(values.media_source)}.`);
|
|
234
|
-
}
|
|
235
|
-
if (portfolioAppId === null) {
|
|
236
|
-
const badApps = [...new Set(slices.map((slice) => slice.app_id).filter((appId) => !values.app_code.has(appId)))];
|
|
237
|
-
if (badApps.length > 0) {
|
|
238
|
-
problems.push(`app_id ${badApps.map((value) => JSON.stringify(value)).join(", ")} is not a ua_spend.app_code value. Valid values: ${sample(values.app_code)}.`);
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
if (problems.length > 0) {
|
|
242
|
-
throw new Error(
|
|
243
|
-
`Plan slice keys do not exist in ua_spend, so every Actuals lookup for this forecast would match nothing:\n`
|
|
244
|
-
+ problems.map((problem) => `- ${problem}`).join("\n"),
|
|
245
|
-
);
|
|
246
|
-
}
|
|
247
|
-
return { portfolioAppId };
|
|
283
|
+
export function validateSliceKeys(slices: PlanSlice[], values: UaDimensionValues): ForecastQualityAssessment {
|
|
284
|
+
return assessForecastQuality(slices, values);
|
|
248
285
|
}
|
|
249
286
|
|
|
250
287
|
export interface ComposeOptions {
|
|
@@ -263,6 +300,7 @@ export async function composeApprovedForecast(plan: unknown, options: ComposeOpt
|
|
|
263
300
|
}
|
|
264
301
|
if (!Array.isArray(source.slices) || source.slices.length === 0) throw new Error("plan.slices must be a non-empty array.");
|
|
265
302
|
const slices = source.slices.map((value, index) => planSlice(value, index));
|
|
303
|
+
if (slices.length > 500) throw new Error("plan.slices cannot exceed 500 slices.");
|
|
266
304
|
|
|
267
305
|
const seen = new Set<string>();
|
|
268
306
|
for (const slice of slices) {
|
|
@@ -289,9 +327,28 @@ export async function composeApprovedForecast(plan: unknown, options: ComposeOpt
|
|
|
289
327
|
+ "Scope deliberately excluded upstream is not a limit — if nothing in scope is missing, the status is complete.",
|
|
290
328
|
);
|
|
291
329
|
}
|
|
330
|
+
if (status === "complete_with_limits") {
|
|
331
|
+
for (const [index, value] of unsupported.entries()) {
|
|
332
|
+
const item = record(value, `plan.unsupported_metrics[${index}]`);
|
|
333
|
+
text(item.metric, `plan.unsupported_metrics[${index}].metric`);
|
|
334
|
+
text(item.reason, `plan.unsupported_metrics[${index}].reason`);
|
|
335
|
+
if (!Array.isArray(item.affected_scope) || item.affected_scope.length === 0) {
|
|
336
|
+
throw new Error(`plan.unsupported_metrics[${index}].affected_scope must be a non-empty array.`);
|
|
337
|
+
}
|
|
338
|
+
for (const [scopeIndex, scope] of item.affected_scope.entries()) text(scope, `plan.unsupported_metrics[${index}].affected_scope[${scopeIndex}]`);
|
|
339
|
+
const evidence = Array.isArray(item.evidence) ? item.evidence : [item.evidence];
|
|
340
|
+
if (evidence.length === 0) throw new Error(`plan.unsupported_metrics[${index}].evidence must not be empty.`);
|
|
341
|
+
for (const [evidenceIndex, entry] of evidence.entries()) text(entry, `plan.unsupported_metrics[${index}].evidence[${evidenceIndex}]`);
|
|
342
|
+
text(item.remediation, `plan.unsupported_metrics[${index}].remediation`);
|
|
343
|
+
text(item.owner_role, `plan.unsupported_metrics[${index}].owner_role`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
292
346
|
|
|
293
|
-
const
|
|
294
|
-
|
|
347
|
+
const dimensionValues = options.loadDimensionValues
|
|
348
|
+
? await options.loadDimensionValues(options.signal)
|
|
349
|
+
: await loadUaDimensionValuesForSlices(slices, options.signal);
|
|
350
|
+
const quality = validateSliceKeys(slices, dimensionValues);
|
|
351
|
+
const { portfolioAppId } = quality;
|
|
295
352
|
|
|
296
353
|
const period = record(source.target_period, "plan.target_period") as unknown as Period;
|
|
297
354
|
const currency = text(source.reporting_currency, "plan.reporting_currency").toUpperCase();
|
|
@@ -310,8 +367,11 @@ export async function composeApprovedForecast(plan: unknown, options: ComposeOpt
|
|
|
310
367
|
// Spend is the decision, not a prediction: an approved allocation is the same
|
|
311
368
|
// number in every scenario. Only revenue moves, through the ROAS assumption.
|
|
312
369
|
const forecastBySlice: ForecastSlice[] = slices.map((slice) => {
|
|
370
|
+
const eligible = quality.eligibleSliceKeys.has(sliceKey(slice));
|
|
313
371
|
const derived = Object.fromEntries(
|
|
314
|
-
SCENARIOS.map((scenario) => [scenario,
|
|
372
|
+
SCENARIOS.map((scenario) => [scenario, eligible
|
|
373
|
+
? sliceScenario(slice.approved_spend, slice.roas[scenario])
|
|
374
|
+
: { revenue: null, roas: null }]),
|
|
315
375
|
) as Record<Scenario, { revenue: number | null; roas: number | null }>;
|
|
316
376
|
return {
|
|
317
377
|
app_id: slice.app_id,
|
|
@@ -357,6 +417,45 @@ export async function composeApprovedForecast(plan: unknown, options: ComposeOpt
|
|
|
357
417
|
roas: scenarioMetric(consolidatedRoas, "ratio", window),
|
|
358
418
|
};
|
|
359
419
|
|
|
420
|
+
const eligibleForecastSlices = forecastBySlice.filter((slice) => quality.eligibleSliceKeys.has(sliceKey(slice)));
|
|
421
|
+
const conclusionStatus: ForecastConclusion["status"] = quality.dataQuality.status === "complete"
|
|
422
|
+
? "available"
|
|
423
|
+
: quality.dataQuality.status === "partial"
|
|
424
|
+
? "available_with_limits"
|
|
425
|
+
: "unavailable";
|
|
426
|
+
const conclusionScope: ForecastConclusion["scope"] = quality.dataQuality.status === "complete"
|
|
427
|
+
? "portfolio"
|
|
428
|
+
: quality.dataQuality.status === "partial"
|
|
429
|
+
? "calculable_slices"
|
|
430
|
+
: "none";
|
|
431
|
+
const conclusionScenario = (metric: "spend" | "revenue"): Record<Scenario, number | null> => Object.fromEntries(
|
|
432
|
+
SCENARIOS.map((scenario) => {
|
|
433
|
+
if (conclusionStatus === "unavailable") return [scenario, null];
|
|
434
|
+
return [scenario, sumScenario(eligibleForecastSlices.map((slice) => slice.metrics[metric][scenario]))];
|
|
435
|
+
}),
|
|
436
|
+
) as Record<Scenario, number | null>;
|
|
437
|
+
const conclusionSpend = conclusionScenario("spend");
|
|
438
|
+
const conclusionRevenue = conclusionScenario("revenue");
|
|
439
|
+
const conclusionRoas = Object.fromEntries(SCENARIOS.map((scenario) => {
|
|
440
|
+
const spend = conclusionSpend[scenario];
|
|
441
|
+
const revenue = conclusionRevenue[scenario];
|
|
442
|
+
return [scenario, spend === null || revenue === null || spend === 0 ? null : revenue / spend];
|
|
443
|
+
})) as Record<Scenario, number | null>;
|
|
444
|
+
const conclusion: ForecastConclusion = {
|
|
445
|
+
status: conclusionStatus,
|
|
446
|
+
scope: conclusionScope,
|
|
447
|
+
metrics: {
|
|
448
|
+
spend: scenarioMetric(conclusionSpend, currency, window),
|
|
449
|
+
revenue: scenarioMetric(conclusionRevenue, currency, window),
|
|
450
|
+
roas: scenarioMetric(conclusionRoas, "ratio", window),
|
|
451
|
+
},
|
|
452
|
+
warning: quality.dataQuality.status === "complete"
|
|
453
|
+
? null
|
|
454
|
+
: quality.dataQuality.status === "partial"
|
|
455
|
+
? `结论仅覆盖可计算切片,占计划支出的 ${(quality.dataQuality.calculable_spend_pct * 100).toFixed(2)}%;未覆盖切片未参与收入和 ROAS 计算。`
|
|
456
|
+
: "没有可计算切片;已保留全部批准预算,但不提供收入或 ROAS 数值结论。",
|
|
457
|
+
};
|
|
458
|
+
|
|
360
459
|
// Portfolio-level series the model measured rather than derived — organic
|
|
361
460
|
// revenue and the like. They pass through untouched apart from the window,
|
|
362
461
|
// which is the artifact's to define, not the plan's.
|
|
@@ -379,9 +478,19 @@ export async function composeApprovedForecast(plan: unknown, options: ComposeOpt
|
|
|
379
478
|
}
|
|
380
479
|
}
|
|
381
480
|
|
|
481
|
+
const artifactStatus = status === "blocked"
|
|
482
|
+
? "blocked"
|
|
483
|
+
: quality.dataQuality.status === "complete"
|
|
484
|
+
? status
|
|
485
|
+
: "complete_with_limits";
|
|
486
|
+
const qualityUnsupported = quality.dataQuality.issues.length === 0 ? [] : [{
|
|
487
|
+
metric: "revenue_roas_by_affected_slice",
|
|
488
|
+
reason: "slice_key_not_in_actuals",
|
|
489
|
+
affected_slice_count: quality.dataQuality.issue_count,
|
|
490
|
+
}];
|
|
382
491
|
const artifact = {
|
|
383
492
|
artifact_type: "approved_cycle_forecast",
|
|
384
|
-
status,
|
|
493
|
+
status: artifactStatus,
|
|
385
494
|
forecast_version: text(source.forecast_version, "plan.forecast_version"),
|
|
386
495
|
strategy_version: text(source.strategy_version, "plan.strategy_version"),
|
|
387
496
|
strategy_review_id: text(source.strategy_review_id, "plan.strategy_review_id"),
|
|
@@ -397,8 +506,10 @@ export async function composeApprovedForecast(plan: unknown, options: ComposeOpt
|
|
|
397
506
|
forecast_by_slice: forecastBySlice,
|
|
398
507
|
consolidated_forecast: consolidated,
|
|
399
508
|
calibration_policy: source.calibration_policy,
|
|
400
|
-
unsupported_metrics: unsupported,
|
|
509
|
+
unsupported_metrics: [...unsupported, ...qualityUnsupported],
|
|
401
510
|
reconciliation_checks: source.reconciliation_checks,
|
|
511
|
+
data_quality: quality.dataQuality,
|
|
512
|
+
conclusion,
|
|
402
513
|
frozen_at: typeof source.frozen_at === "string" && source.frozen_at.trim()
|
|
403
514
|
? source.frozen_at
|
|
404
515
|
: (options.now?.() ?? new Date()).toISOString().replace(/\.\d{3}Z$/, "Z"),
|
|
@@ -409,7 +520,10 @@ export async function composeApprovedForecast(plan: unknown, options: ComposeOpt
|
|
|
409
520
|
diagnostics: {
|
|
410
521
|
slice_count: slices.length,
|
|
411
522
|
stopped_slice_count: slices.filter((slice) => slice.approved_spend === 0).length,
|
|
523
|
+
limited_slice_count: quality.limitedSliceKeys.size,
|
|
412
524
|
total_approved_spend: slices.reduce((total, slice) => total + slice.approved_spend, 0),
|
|
525
|
+
calculable_approved_spend: quality.dataQuality.calculable_spend,
|
|
526
|
+
calculable_spend_pct: quality.dataQuality.calculable_spend_pct,
|
|
413
527
|
validated_dimensions: true,
|
|
414
528
|
portfolio_placeholder_app_id: portfolioAppId,
|
|
415
529
|
},
|
|
@@ -35,6 +35,35 @@ export interface ForecastSlice {
|
|
|
35
35
|
metrics: Record<string, ScenarioMetric>;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
export interface ForecastDataQualityIssue {
|
|
39
|
+
issue_id: string;
|
|
40
|
+
code: "slice_key_not_in_actuals";
|
|
41
|
+
severity: "warning";
|
|
42
|
+
slice: Pick<ForecastSlice, "app_id" | "store" | "channel_group">;
|
|
43
|
+
affected_metrics: string[];
|
|
44
|
+
disposition: "excluded_from_calculation";
|
|
45
|
+
evidence: string[];
|
|
46
|
+
remediation: string;
|
|
47
|
+
owner_role: "data_steward";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ForecastDataQuality {
|
|
51
|
+
status: "complete" | "partial" | "unavailable";
|
|
52
|
+
planned_spend: number;
|
|
53
|
+
calculable_spend: number;
|
|
54
|
+
calculable_spend_pct: number;
|
|
55
|
+
excluded_spend: number;
|
|
56
|
+
issue_count: number;
|
|
57
|
+
issues: ForecastDataQualityIssue[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface ForecastConclusion {
|
|
61
|
+
status: "available" | "available_with_limits" | "unavailable";
|
|
62
|
+
scope: "portfolio" | "calculable_slices" | "none";
|
|
63
|
+
metrics: Record<string, ScenarioMetric>;
|
|
64
|
+
warning: string | null;
|
|
65
|
+
}
|
|
66
|
+
|
|
38
67
|
export interface CalibrationPolicy {
|
|
39
68
|
stop_loss_roas_lt: number;
|
|
40
69
|
deviation_warning_abs_gte: number;
|
|
@@ -62,6 +91,8 @@ export interface ApprovedCycleForecastInput {
|
|
|
62
91
|
calibration_policy: CalibrationPolicy;
|
|
63
92
|
unsupported_metrics: unknown[];
|
|
64
93
|
reconciliation_checks: unknown[];
|
|
94
|
+
data_quality?: ForecastDataQuality;
|
|
95
|
+
conclusion?: ForecastConclusion;
|
|
65
96
|
frozen_at: string;
|
|
66
97
|
}
|
|
67
98
|
|
|
@@ -235,6 +266,26 @@ function jsonArray(value: unknown, path: string): unknown[] {
|
|
|
235
266
|
return array(value, path).map((item, index) => jsonValue(item, `${path}[${index}]`));
|
|
236
267
|
}
|
|
237
268
|
|
|
269
|
+
function actionableUnsupportedMetrics(value: unknown, path: string): unknown[] {
|
|
270
|
+
const items = jsonArray(value, path);
|
|
271
|
+
for (const [index, raw] of items.entries()) {
|
|
272
|
+
const itemPath = `${path}[${index}]`;
|
|
273
|
+
const item = record(raw, itemPath);
|
|
274
|
+
string(item.metric, `${itemPath}.metric`);
|
|
275
|
+
const reason = string(item.reason, `${itemPath}.reason`);
|
|
276
|
+
if (reason === "slice_key_not_in_actuals") continue;
|
|
277
|
+
const scopes = strings(item.affected_scope, `${itemPath}.affected_scope`);
|
|
278
|
+
if (scopes.length === 0) throw new Error(`${itemPath}.affected_scope must not be empty.`);
|
|
279
|
+
const evidence = Array.isArray(item.evidence)
|
|
280
|
+
? strings(item.evidence, `${itemPath}.evidence`)
|
|
281
|
+
: [string(item.evidence, `${itemPath}.evidence`)];
|
|
282
|
+
if (evidence.length === 0) throw new Error(`${itemPath}.evidence must not be empty.`);
|
|
283
|
+
string(item.remediation, `${itemPath}.remediation`);
|
|
284
|
+
string(item.owner_role, `${itemPath}.owner_role`);
|
|
285
|
+
}
|
|
286
|
+
return items;
|
|
287
|
+
}
|
|
288
|
+
|
|
238
289
|
function strings(value: unknown, path: string): string[] {
|
|
239
290
|
return array(value, path).map((item, index) => string(item, `${path}[${index}]`));
|
|
240
291
|
}
|
|
@@ -316,6 +367,70 @@ function scenarioMetrics(value: unknown, path: string): Record<string, ScenarioM
|
|
|
316
367
|
return Object.fromEntries(entries.map(([name, metric]) => [string(name, `${path} key`), scenarioMetric(metric, `${path}.${name}`)]));
|
|
317
368
|
}
|
|
318
369
|
|
|
370
|
+
function forecastDataQuality(value: unknown, path: string): ForecastDataQuality {
|
|
371
|
+
const source = record(value, path);
|
|
372
|
+
exactKeys(source, ["status", "planned_spend", "calculable_spend", "calculable_spend_pct", "excluded_spend", "issue_count", "issues"], path);
|
|
373
|
+
const issues = array(source.issues, `${path}.issues`).map((raw, index) => {
|
|
374
|
+
const issuePath = `${path}.issues[${index}]`;
|
|
375
|
+
const issue = record(raw, issuePath);
|
|
376
|
+
exactKeys(issue, ["issue_id", "code", "severity", "slice", "affected_metrics", "disposition", "evidence", "remediation", "owner_role"], issuePath);
|
|
377
|
+
const slice = record(issue.slice, `${issuePath}.slice`);
|
|
378
|
+
exactKeys(slice, ["app_id", "store", "channel_group"], `${issuePath}.slice`);
|
|
379
|
+
return {
|
|
380
|
+
issue_id: string(issue.issue_id, `${issuePath}.issue_id`),
|
|
381
|
+
code: enumValue(issue.code, ["slice_key_not_in_actuals"] as const, `${issuePath}.code`),
|
|
382
|
+
severity: enumValue(issue.severity, ["warning"] as const, `${issuePath}.severity`),
|
|
383
|
+
slice: {
|
|
384
|
+
app_id: string(slice.app_id, `${issuePath}.slice.app_id`),
|
|
385
|
+
store: string(slice.store, `${issuePath}.slice.store`),
|
|
386
|
+
channel_group: string(slice.channel_group, `${issuePath}.slice.channel_group`),
|
|
387
|
+
},
|
|
388
|
+
affected_metrics: strings(issue.affected_metrics, `${issuePath}.affected_metrics`),
|
|
389
|
+
disposition: enumValue(issue.disposition, ["excluded_from_calculation"] as const, `${issuePath}.disposition`),
|
|
390
|
+
evidence: strings(issue.evidence, `${issuePath}.evidence`),
|
|
391
|
+
remediation: string(issue.remediation, `${issuePath}.remediation`),
|
|
392
|
+
owner_role: enumValue(issue.owner_role, ["data_steward"] as const, `${issuePath}.owner_role`),
|
|
393
|
+
};
|
|
394
|
+
});
|
|
395
|
+
for (const [index, issue] of issues.entries()) {
|
|
396
|
+
const allowedAffectedMetrics = new Set(["actuals_comparison", "revenue", "roas", "execution"]);
|
|
397
|
+
const actualAffectedMetrics = new Set(issue.affected_metrics);
|
|
398
|
+
if (actualAffectedMetrics.size !== issue.affected_metrics.length
|
|
399
|
+
|| actualAffectedMetrics.size !== allowedAffectedMetrics.size
|
|
400
|
+
|| [...allowedAffectedMetrics].some((metric) => !actualAffectedMetrics.has(metric))) {
|
|
401
|
+
throw new Error(`${path}.issues[${index}].affected_metrics must contain actuals_comparison, revenue, roas, and execution exactly once.`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
const plannedSpend = nonNegative(source.planned_spend, `${path}.planned_spend`) as number;
|
|
405
|
+
const calculableSpend = nonNegative(source.calculable_spend, `${path}.calculable_spend`) as number;
|
|
406
|
+
const excludedSpend = nonNegative(source.excluded_spend, `${path}.excluded_spend`) as number;
|
|
407
|
+
const calculableSpendPct = nonNegative(source.calculable_spend_pct, `${path}.calculable_spend_pct`) as number;
|
|
408
|
+
const issueCount = nonNegative(source.issue_count, `${path}.issue_count`) as number;
|
|
409
|
+
if (!Number.isInteger(issueCount) || issueCount !== issues.length) throw new Error(`${path}.issue_count must equal issues.length.`);
|
|
410
|
+
if (calculableSpendPct > 1) throw new Error(`${path}.calculable_spend_pct cannot exceed 1.`);
|
|
411
|
+
if (Math.abs(plannedSpend - calculableSpend - excludedSpend) > 0.01) throw new Error(`${path} spend coverage does not reconcile.`);
|
|
412
|
+
return {
|
|
413
|
+
status: enumValue(source.status, ["complete", "partial", "unavailable"] as const, `${path}.status`),
|
|
414
|
+
planned_spend: plannedSpend,
|
|
415
|
+
calculable_spend: calculableSpend,
|
|
416
|
+
calculable_spend_pct: calculableSpendPct,
|
|
417
|
+
excluded_spend: excludedSpend,
|
|
418
|
+
issue_count: issueCount,
|
|
419
|
+
issues,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function forecastConclusion(value: unknown, path: string): ForecastConclusion {
|
|
424
|
+
const source = record(value, path);
|
|
425
|
+
exactKeys(source, ["status", "scope", "metrics", "warning"], path);
|
|
426
|
+
return {
|
|
427
|
+
status: enumValue(source.status, ["available", "available_with_limits", "unavailable"] as const, `${path}.status`),
|
|
428
|
+
scope: enumValue(source.scope, ["portfolio", "calculable_slices", "none"] as const, `${path}.scope`),
|
|
429
|
+
metrics: scenarioMetrics(source.metrics, `${path}.metrics`),
|
|
430
|
+
warning: source.warning === null ? null : string(source.warning, `${path}.warning`),
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
319
434
|
export function sliceKey(value: { app_id: string; store: string; channel_group: string }): string {
|
|
320
435
|
return `${value.app_id}\u0000${value.store}\u0000${value.channel_group}`;
|
|
321
436
|
}
|
|
@@ -366,7 +481,7 @@ function validateFinancialMetricSemantics(
|
|
|
366
481
|
}
|
|
367
482
|
|
|
368
483
|
function validateApprovedForecast(source: Record<string, unknown>): ApprovedCycleForecastInput {
|
|
369
|
-
|
|
484
|
+
requiredAndOptionalKeys(source, TOP_LEVEL_KEYS.approved_cycle_forecast, ["data_quality", "conclusion"], "artifact");
|
|
370
485
|
const targetPeriod = period(source.target_period, "artifact.target_period");
|
|
371
486
|
const allocations = array(source.approved_allocation, "artifact.approved_allocation").map((value, index) => {
|
|
372
487
|
const path = `artifact.approved_allocation[${index}]`;
|
|
@@ -460,6 +575,85 @@ function validateApprovedForecast(source: Record<string, unknown>): ApprovedCycl
|
|
|
460
575
|
}
|
|
461
576
|
|
|
462
577
|
const status = enumValue(source.status, ["complete", "complete_with_limits", "blocked"] as const, "artifact.status");
|
|
578
|
+
const unsupportedMetrics = actionableUnsupportedMetrics(source.unsupported_metrics, "artifact.unsupported_metrics");
|
|
579
|
+
if (status === "complete" && unsupportedMetrics.length > 0) {
|
|
580
|
+
throw new Error("A complete forecast cannot carry unsupported_metrics; use complete_with_limits.");
|
|
581
|
+
}
|
|
582
|
+
if (status === "complete_with_limits" && unsupportedMetrics.length === 0) {
|
|
583
|
+
throw new Error("A complete_with_limits forecast must describe at least one unsupported metric or data limit.");
|
|
584
|
+
}
|
|
585
|
+
const dataQuality = source.data_quality === undefined ? undefined : forecastDataQuality(source.data_quality, "artifact.data_quality");
|
|
586
|
+
const conclusion = source.conclusion === undefined ? undefined : forecastConclusion(source.conclusion, "artifact.conclusion");
|
|
587
|
+
if ((dataQuality === undefined) !== (conclusion === undefined)) {
|
|
588
|
+
throw new Error("artifact.data_quality and artifact.conclusion must be supplied together.");
|
|
589
|
+
}
|
|
590
|
+
const declaresSliceKeyLimit = unsupportedMetrics.some((item) => item !== null
|
|
591
|
+
&& typeof item === "object"
|
|
592
|
+
&& !Array.isArray(item)
|
|
593
|
+
&& (item as Record<string, unknown>).reason === "slice_key_not_in_actuals");
|
|
594
|
+
if (declaresSliceKeyLimit && (!dataQuality || dataQuality.issues.length === 0)) {
|
|
595
|
+
throw new Error("A slice_key_not_in_actuals limit requires data_quality issues and a partial conclusion.");
|
|
596
|
+
}
|
|
597
|
+
if (status === "complete" && dataQuality && dataQuality.status !== "complete") {
|
|
598
|
+
throw new Error("A complete forecast cannot carry partial or unavailable data quality.");
|
|
599
|
+
}
|
|
600
|
+
if (dataQuality && conclusion) {
|
|
601
|
+
const issueKeys = new Set<string>();
|
|
602
|
+
for (const issue of dataQuality.issues) {
|
|
603
|
+
const key = sliceKey(issue.slice);
|
|
604
|
+
if (!allocationKeys.has(key)) throw new Error(`artifact.data_quality issue slice is not in approved_allocation: ${key.replaceAll("\u0000", " / ")}.`);
|
|
605
|
+
if (issueKeys.has(key)) throw new Error(`artifact.data_quality contains duplicate issue slice ${key.replaceAll("\u0000", " / ")}.`);
|
|
606
|
+
issueKeys.add(key);
|
|
607
|
+
const affectedForecast = forecastSlices.find((slice) => sliceKey(slice) === key) as ForecastSlice;
|
|
608
|
+
for (const scenario of SCENARIOS) {
|
|
609
|
+
if (affectedForecast.metrics.revenue[scenario] !== null || affectedForecast.metrics.roas?.[scenario] !== null) {
|
|
610
|
+
throw new Error(`artifact.data_quality affected slice ${key.replaceAll("\u0000", " / ")} must keep revenue and ROAS null.`);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
const expectedExcludedSpend = allocations
|
|
615
|
+
.filter((allocation) => issueKeys.has(sliceKey(allocation)))
|
|
616
|
+
.reduce((total, allocation) => total + allocation.approved_spend, 0);
|
|
617
|
+
const expectedCalculableSpend = allocationTotal - expectedExcludedSpend;
|
|
618
|
+
reconcileNumber(dataQuality.planned_spend, allocationTotal, "artifact.data_quality.planned_spend vs approved allocation");
|
|
619
|
+
reconcileNumber(dataQuality.excluded_spend, expectedExcludedSpend, "artifact.data_quality.excluded_spend vs affected allocation");
|
|
620
|
+
reconcileNumber(dataQuality.calculable_spend, expectedCalculableSpend, "artifact.data_quality.calculable_spend vs eligible allocation");
|
|
621
|
+
const expectedPct = allocationTotal === 0 ? (issueKeys.size === 0 ? 1 : 0) : expectedCalculableSpend / allocationTotal;
|
|
622
|
+
reconcileNumber(dataQuality.calculable_spend_pct, expectedPct, "artifact.data_quality.calculable_spend_pct coverage");
|
|
623
|
+
const expectedQualityStatus: ForecastDataQuality["status"] = issueKeys.size === 0
|
|
624
|
+
? "complete"
|
|
625
|
+
: expectedCalculableSpend === 0
|
|
626
|
+
? "unavailable"
|
|
627
|
+
: "partial";
|
|
628
|
+
if (dataQuality.status !== expectedQualityStatus) throw new Error(`artifact.data_quality.status must be ${expectedQualityStatus}.`);
|
|
629
|
+
const expectedConclusionStatus: ForecastConclusion["status"] = expectedQualityStatus === "complete"
|
|
630
|
+
? "available"
|
|
631
|
+
: expectedQualityStatus === "partial"
|
|
632
|
+
? "available_with_limits"
|
|
633
|
+
: "unavailable";
|
|
634
|
+
const expectedConclusionScope: ForecastConclusion["scope"] = expectedQualityStatus === "complete"
|
|
635
|
+
? "portfolio"
|
|
636
|
+
: expectedQualityStatus === "partial"
|
|
637
|
+
? "calculable_slices"
|
|
638
|
+
: "none";
|
|
639
|
+
if (conclusion.status !== expectedConclusionStatus || conclusion.scope !== expectedConclusionScope) {
|
|
640
|
+
throw new Error(`artifact.conclusion must use ${expectedConclusionStatus} / ${expectedConclusionScope} for its data coverage.`);
|
|
641
|
+
}
|
|
642
|
+
validateFinancialMetricSemantics(conclusion.metrics, reportingCurrency, expectedWindow, "artifact.conclusion.metrics");
|
|
643
|
+
reconcileRoas(conclusion.metrics, "artifact.conclusion.metrics");
|
|
644
|
+
const eligibleForecastSlices = forecastSlices.filter((slice) => !issueKeys.has(sliceKey(slice)));
|
|
645
|
+
for (const metricName of ["spend", "revenue"] as const) {
|
|
646
|
+
for (const scenario of SCENARIOS) {
|
|
647
|
+
const values = eligibleForecastSlices.map((slice) => slice.metrics[metricName][scenario]);
|
|
648
|
+
const expected = expectedQualityStatus === "unavailable"
|
|
649
|
+
? null
|
|
650
|
+
: values.every((value) => value !== null)
|
|
651
|
+
? values.reduce<number>((total, value) => total + (value as number), 0)
|
|
652
|
+
: null;
|
|
653
|
+
reconcileNumber(conclusion.metrics[metricName][scenario], expected, `artifact.conclusion.metrics.${metricName}.${scenario}`);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
463
657
|
const approvalConditionsSatisfied = boolean(source.approval_conditions_satisfied, "artifact.approval_conditions_satisfied");
|
|
464
658
|
if (status !== "blocked" && !approvalConditionsSatisfied) {
|
|
465
659
|
throw new Error("A non-blocked approved forecast requires all approval conditions to be satisfied.");
|
|
@@ -482,8 +676,10 @@ function validateApprovedForecast(source: Record<string, unknown>): ApprovedCycl
|
|
|
482
676
|
forecast_by_slice: forecastSlices,
|
|
483
677
|
consolidated_forecast: consolidated,
|
|
484
678
|
calibration_policy: policy,
|
|
485
|
-
unsupported_metrics:
|
|
679
|
+
unsupported_metrics: unsupportedMetrics,
|
|
486
680
|
reconciliation_checks: jsonArray(source.reconciliation_checks, "artifact.reconciliation_checks"),
|
|
681
|
+
...(dataQuality ? { data_quality: dataQuality } : {}),
|
|
682
|
+
...(conclusion ? { conclusion } : {}),
|
|
487
683
|
frozen_at: isoTimestamp(source.frozen_at, "artifact.frozen_at"),
|
|
488
684
|
};
|
|
489
685
|
}
|
|
@@ -617,9 +813,13 @@ export function validateExecutionAgainstForecast(
|
|
|
617
813
|
if (execution.strategy_version !== forecast.strategy_version) throw new Error("Execution receipt strategy_version does not match the approved forecast.");
|
|
618
814
|
if (execution.human_approval_id !== forecast.human_approval_id) throw new Error("Execution receipt human_approval_id does not match the approved forecast.");
|
|
619
815
|
const allocationByKey = new Map(forecast.approved_allocation.map((allocation) => [sliceKey(allocation), allocation]));
|
|
816
|
+
const isolatedExecutionKeys = new Set((forecast.data_quality?.issues ?? []).map((issue) => sliceKey(issue.slice)));
|
|
620
817
|
const executionKeys = new Set<string>();
|
|
621
818
|
for (const slice of execution.slices) {
|
|
622
819
|
const key = sliceKey(slice);
|
|
820
|
+
if (isolatedExecutionKeys.has(key)) {
|
|
821
|
+
throw new Error(`Execution receipt contains a slice isolated by forecast data quality: ${key.replaceAll("\u0000", " / ")}.`);
|
|
822
|
+
}
|
|
623
823
|
executionKeys.add(key);
|
|
624
824
|
const allocation = allocationByKey.get(key);
|
|
625
825
|
if (!allocation) throw new Error(`Execution receipt contains an unapproved slice ${key.replaceAll("\u0000", " / ")}.`);
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import {
|
|
2
|
+
sliceKey,
|
|
3
|
+
type ForecastDataQuality,
|
|
4
|
+
type ForecastDataQualityIssue,
|
|
5
|
+
} from "./contracts.ts";
|
|
6
|
+
|
|
7
|
+
export interface ForecastQualitySlice {
|
|
8
|
+
app_id: string;
|
|
9
|
+
store: string;
|
|
10
|
+
channel_group: string;
|
|
11
|
+
approved_spend: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ForecastDimensionSnapshot {
|
|
15
|
+
app_code: Set<string>;
|
|
16
|
+
platform: Set<string>;
|
|
17
|
+
media_source: Set<string>;
|
|
18
|
+
/** Canonical app_code / platform / media_source combinations observed in ua_spend. */
|
|
19
|
+
tuples?: Set<string>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ForecastQualityAssessment {
|
|
23
|
+
portfolioAppId: string | null;
|
|
24
|
+
eligibleSliceKeys: Set<string>;
|
|
25
|
+
limitedSliceKeys: Set<string>;
|
|
26
|
+
dataQuality: ForecastDataQuality;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function sample(values: Set<string>, limit = 8): string {
|
|
30
|
+
const list = [...values].sort();
|
|
31
|
+
return list.length <= limit ? list.join(", ") : `${list.slice(0, limit).join(", ")}, … (${list.length} total)`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function issueForSlice(
|
|
35
|
+
slice: ForecastQualitySlice,
|
|
36
|
+
index: number,
|
|
37
|
+
evidence: string[],
|
|
38
|
+
): ForecastDataQualityIssue {
|
|
39
|
+
return {
|
|
40
|
+
issue_id: `DQ-SLICE-${String(index + 1).padStart(3, "0")}`,
|
|
41
|
+
code: "slice_key_not_in_actuals",
|
|
42
|
+
severity: "warning",
|
|
43
|
+
slice: {
|
|
44
|
+
app_id: slice.app_id,
|
|
45
|
+
store: slice.store,
|
|
46
|
+
channel_group: slice.channel_group,
|
|
47
|
+
},
|
|
48
|
+
affected_metrics: ["actuals_comparison", "revenue", "roas", "execution"],
|
|
49
|
+
disposition: "excluded_from_calculation",
|
|
50
|
+
evidence,
|
|
51
|
+
remediation:
|
|
52
|
+
"请由数据工作人员修正 ua_spend 的 app_code/platform/media_source 映射,或更新计划切片为数据集中真实存在的组合。",
|
|
53
|
+
owner_role: "data_steward",
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Classify slice identity problems as data-quality limits, not governance
|
|
59
|
+
* failures. The allocation remains intact; callers use eligibleSliceKeys to
|
|
60
|
+
* decide which derived metrics may be calculated and which slices may execute.
|
|
61
|
+
*/
|
|
62
|
+
export function assessForecastQuality(
|
|
63
|
+
slices: ForecastQualitySlice[],
|
|
64
|
+
values: ForecastDimensionSnapshot,
|
|
65
|
+
): ForecastQualityAssessment {
|
|
66
|
+
const appIds = new Set(slices.map((slice) => slice.app_id));
|
|
67
|
+
const collapsed = appIds.size === 1 ? [...appIds][0] : null;
|
|
68
|
+
const portfolioAppId = collapsed !== null && !values.app_code.has(collapsed) ? collapsed : null;
|
|
69
|
+
const eligibleSliceKeys = new Set<string>();
|
|
70
|
+
const limitedSliceKeys = new Set<string>();
|
|
71
|
+
const issues: ForecastDataQualityIssue[] = [];
|
|
72
|
+
|
|
73
|
+
for (const [index, slice] of slices.entries()) {
|
|
74
|
+
const evidence: string[] = [];
|
|
75
|
+
if (portfolioAppId === null && !values.app_code.has(slice.app_id)) {
|
|
76
|
+
evidence.push(`app_id ${JSON.stringify(slice.app_id)} is not a ua_spend.app_code value. Valid values: ${sample(values.app_code)}.`);
|
|
77
|
+
}
|
|
78
|
+
if (!values.platform.has(slice.store)) {
|
|
79
|
+
evidence.push(`store ${JSON.stringify(slice.store)} is not a ua_spend.platform value. Valid values: ${sample(values.platform)}.`);
|
|
80
|
+
}
|
|
81
|
+
if (!values.media_source.has(slice.channel_group)) {
|
|
82
|
+
evidence.push(`channel_group ${JSON.stringify(slice.channel_group)} is not a ua_spend.media_source value. Valid values: ${sample(values.media_source)}.`);
|
|
83
|
+
}
|
|
84
|
+
if (
|
|
85
|
+
evidence.length === 0
|
|
86
|
+
&& portfolioAppId === null
|
|
87
|
+
&& values.tuples
|
|
88
|
+
&& !values.tuples.has(sliceKey(slice))
|
|
89
|
+
) {
|
|
90
|
+
evidence.push(
|
|
91
|
+
`The app_code/platform/media_source combination ${slice.app_id} / ${slice.store} / ${slice.channel_group} does not occur in ua_spend.`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
if (
|
|
95
|
+
evidence.length === 0
|
|
96
|
+
&& portfolioAppId !== null
|
|
97
|
+
&& values.tuples
|
|
98
|
+
&& ![...values.tuples].some((tuple) => tuple.endsWith(`\u0000${slice.store}\u0000${slice.channel_group}`))
|
|
99
|
+
) {
|
|
100
|
+
evidence.push(
|
|
101
|
+
`The platform/media_source combination ${slice.store} / ${slice.channel_group} does not occur in ua_spend portfolio data.`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const key = sliceKey(slice);
|
|
106
|
+
if (evidence.length === 0) eligibleSliceKeys.add(key);
|
|
107
|
+
else {
|
|
108
|
+
limitedSliceKeys.add(key);
|
|
109
|
+
issues.push(issueForSlice(slice, index, evidence));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const plannedSpend = slices.reduce((total, slice) => total + slice.approved_spend, 0);
|
|
114
|
+
const calculableSpend = slices
|
|
115
|
+
.filter((slice) => eligibleSliceKeys.has(sliceKey(slice)))
|
|
116
|
+
.reduce((total, slice) => total + slice.approved_spend, 0);
|
|
117
|
+
const excludedSpend = plannedSpend - calculableSpend;
|
|
118
|
+
const status = issues.length === 0
|
|
119
|
+
? "complete"
|
|
120
|
+
: calculableSpend === 0
|
|
121
|
+
? "unavailable"
|
|
122
|
+
: "partial";
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
portfolioAppId,
|
|
126
|
+
eligibleSliceKeys,
|
|
127
|
+
limitedSliceKeys,
|
|
128
|
+
dataQuality: {
|
|
129
|
+
status,
|
|
130
|
+
planned_spend: plannedSpend,
|
|
131
|
+
calculable_spend: calculableSpend,
|
|
132
|
+
calculable_spend_pct: plannedSpend === 0 ? (issues.length === 0 ? 1 : 0) : calculableSpend / plannedSpend,
|
|
133
|
+
excluded_spend: excludedSpend,
|
|
134
|
+
issue_count: issues.length,
|
|
135
|
+
issues,
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
@@ -142,7 +142,8 @@ export default function fpaArtifactsExtension(pi: ExtensionAPI): void {
|
|
|
142
142
|
label: "Compose Approved Forecast",
|
|
143
143
|
description:
|
|
144
144
|
"Derive a complete, self-consistent approved_cycle_forecast from a compact plan file holding only the approved allocation and its per-slice ROAS assumptions. " +
|
|
145
|
-
"Computes every scenario metric, the consolidated roll-up, units, windows, and frozen_at, and checks
|
|
145
|
+
"Computes every scenario metric, the consolidated roll-up, units, windows, and frozen_at, and checks plan slice keys against the exact dimension combinations ua_spend actually uses. " +
|
|
146
|
+
"Data-quality mismatches are isolated into complete_with_limits with coverage and repair diagnostics; approved allocation is never dropped or renormalized. " +
|
|
146
147
|
"Returns the artifact for fpa_artifact_commit to freeze; writes nothing.",
|
|
147
148
|
promptSnippet: "Derive a complete approved forecast from a compact plan",
|
|
148
149
|
promptGuidelines: [
|
|
@@ -717,8 +717,8 @@ async function defaultWorker(event: DashboardRefreshEvent, projectRoot: string,
|
|
|
717
717
|
...(event.forward_forecast_refs ? { forwardForecastRefs: event.forward_forecast_refs } : {}),
|
|
718
718
|
}, signal);
|
|
719
719
|
if (result.sliceKeyMismatch) throw new Error(`Dashboard publish refused. ${result.sliceKeyMismatch}`);
|
|
720
|
-
if (result.forecast.status
|
|
721
|
-
throw new Error("Dashboard publish requires a
|
|
720
|
+
if (result.forecast.status === "blocked" || !result.forecast.approval_conditions_satisfied) {
|
|
721
|
+
throw new Error("Dashboard publish requires a completed approved forecast with all approval conditions satisfied.");
|
|
722
722
|
}
|
|
723
723
|
const generationId = dashboardBuildFingerprint(result.build, result.projector);
|
|
724
724
|
if (event.desired_generation_id && generationId !== event.desired_generation_id) {
|
|
@@ -218,8 +218,8 @@ export function projectCycleOperatingProjection(input: CycleOperatingProjectionI
|
|
|
218
218
|
throw new Error("next_forecast target period must be the exact successor of the current cycle in the same timezone.");
|
|
219
219
|
}
|
|
220
220
|
if (next && next.reporting_currency !== current.reporting_currency) throw new Error("next_forecast reporting_currency must match current_forecast.");
|
|
221
|
-
if (next && (next.status
|
|
222
|
-
throw new Error("next_forecast must be
|
|
221
|
+
if (next && (next.status === "blocked" || !next.approval_conditions_satisfied)) {
|
|
222
|
+
throw new Error("next_forecast must be completed with approval conditions satisfied.");
|
|
223
223
|
}
|
|
224
224
|
|
|
225
225
|
const forecastSpend = scenario(current.consolidated_forecast.spend, "current_forecast.consolidated_forecast.spend");
|
|
@@ -253,9 +253,10 @@ export function projectCycleOperatingProjection(input: CycleOperatingProjectionI
|
|
|
253
253
|
const nextSpend = next ? scenario(next.consolidated_forecast.spend, "next_forecast.consolidated_forecast.spend") : null;
|
|
254
254
|
const nextRevenue = next ? scenario(next.consolidated_forecast.revenue, "next_forecast.consolidated_forecast.revenue") : null;
|
|
255
255
|
const nextRoas = next ? scenario(next.consolidated_forecast.roas, "next_forecast.consolidated_forecast.roas") : null;
|
|
256
|
-
const nextHasLimits = !!next && (
|
|
256
|
+
const nextHasLimits = !!next && (next.status === "complete_with_limits"
|
|
257
|
+
|| nextSpend?.base === null || nextRevenue?.base === null || nextRoas?.base === null
|
|
257
258
|
|| next.approved_allocation.some((item) => !item.owner));
|
|
258
|
-
if (nextHasLimits) warnings.push("The next-cycle forecast is approved with
|
|
259
|
+
if (nextHasLimits) warnings.push("The next-cycle forecast is approved with limits; unsupported expectations remain null and documented data issues still require remediation.");
|
|
259
260
|
if (next?.approved_allocation.some((item) => !item.owner)) warnings.push("At least one next-cycle allocation has no accountable owner; strategy readiness is limited.");
|
|
260
261
|
|
|
261
262
|
return {
|
|
@@ -26,7 +26,7 @@ export interface ForwardOutlookProjection {
|
|
|
26
26
|
function approvedForecast(value: unknown, path: string): ApprovedCycleForecastInput {
|
|
27
27
|
const artifact = validateArtifact(value);
|
|
28
28
|
if (artifact.artifact_type !== "approved_cycle_forecast") throw new Error(`${path} must be an approved_cycle_forecast.`);
|
|
29
|
-
if (artifact.status
|
|
29
|
+
if (artifact.status === "blocked" || !artifact.approval_conditions_satisfied) throw new Error(`${path} must be completed and approved.`);
|
|
30
30
|
return artifact;
|
|
31
31
|
}
|
|
32
32
|
|
|
@@ -46,6 +46,7 @@ export function projectForwardOutlook(input: {
|
|
|
46
46
|
const current = approvedForecast(input.current_forecast, "current_forecast");
|
|
47
47
|
if (!Array.isArray(input.forward_forecasts) || input.forward_forecasts.length > 6) throw new Error("forward_forecasts must contain at most six approved forecasts.");
|
|
48
48
|
const months: ForwardOutlookProjection["months"] = [];
|
|
49
|
+
const warnings: string[] = [];
|
|
49
50
|
let expectedStart = current.target_period.end_exclusive;
|
|
50
51
|
for (const [index, item] of input.forward_forecasts.entries()) {
|
|
51
52
|
const forecast = approvedForecast(item.forecast, `forward_forecasts[${index}].forecast`);
|
|
@@ -63,8 +64,10 @@ export function projectForwardOutlook(input: {
|
|
|
63
64
|
expected_revenue: values(forecast.consolidated_forecast.revenue, `forward_forecasts[${index}].revenue`),
|
|
64
65
|
expected_roas: values(forecast.consolidated_forecast.roas, `forward_forecasts[${index}].roas`),
|
|
65
66
|
});
|
|
67
|
+
if (forecast.status === "complete_with_limits") {
|
|
68
|
+
warnings.push(`${forecast.forecast_version} has data limits; unsupported expectations remain null.`);
|
|
69
|
+
}
|
|
66
70
|
}
|
|
67
|
-
const warnings: string[] = [];
|
|
68
71
|
if (months.length < 6) warnings.push(`Only ${months.length} of 6 forward monthly forecasts are approved and linked.`);
|
|
69
72
|
if (months.some((month) => month.expected_revenue.base === null || month.expected_spend.base === null || month.expected_roas.base === null)) {
|
|
70
73
|
warnings.push("At least one forward month has unsupported base metrics; its expectation remains unavailable.");
|
|
@@ -384,8 +384,8 @@ export default function fpaDashboardExtension(pi: ExtensionAPI): void {
|
|
|
384
384
|
}
|
|
385
385
|
|
|
386
386
|
if (sliceKeyMismatch) throw new Error(`Dashboard publish refused. ${sliceKeyMismatch}`);
|
|
387
|
-
if (forecast.status
|
|
388
|
-
throw new Error("Dashboard publish requires a
|
|
387
|
+
if (forecast.status === "blocked" || !forecast.approval_conditions_satisfied) {
|
|
388
|
+
throw new Error("Dashboard publish requires a completed approved forecast with all approval conditions satisfied.");
|
|
389
389
|
}
|
|
390
390
|
if (!params.expected_preview_fingerprint) throw new Error("Publish requires expected_preview_fingerprint from a preceding preview.");
|
|
391
391
|
if (params.expected_preview_fingerprint !== previewFingerprint) {
|
|
@@ -226,10 +226,11 @@ function joinSlices(
|
|
|
226
226
|
const forecastByKey = new Map(forecast.forecast_by_slice.map((slice) => [sliceKey(slice), slice]));
|
|
227
227
|
const actualByKey = new Map(actuals.slices.map((slice) => [sliceKey(slice), slice]));
|
|
228
228
|
const executionByKey = new Map((execution?.slices ?? []).map((slice) => [sliceKey(slice), slice]));
|
|
229
|
+
const isolatedKeys = new Set((forecast.data_quality?.issues ?? []).map((issue) => sliceKey(issue.slice)));
|
|
229
230
|
return forecast.approved_allocation.map((allocation) => {
|
|
230
231
|
const forecastSlice = forecastByKey.get(sliceKey(allocation));
|
|
231
232
|
if (!forecastSlice) throw new Error(`Forecast slice is missing for ${allocation.app_id} / ${allocation.store} / ${allocation.channel_group}.`);
|
|
232
|
-
const actual = actualByKey.get(sliceKey(allocation)) ?? null;
|
|
233
|
+
const actual = isolatedKeys.has(sliceKey(allocation)) ? null : actualByKey.get(sliceKey(allocation)) ?? null;
|
|
233
234
|
const forecastRoas = safeDiv(
|
|
234
235
|
forecastSlice.metrics.revenue?.base ?? null,
|
|
235
236
|
forecastSlice.metrics.spend?.base ?? null,
|
|
@@ -390,8 +391,64 @@ export function projectDashboard(input: ProjectionInput): DashboardBuild {
|
|
|
390
391
|
const deviationCount = alerts.rows.length - stopCount;
|
|
391
392
|
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)`;
|
|
392
393
|
const asOfNote = actuals.data_as_of === ACTUALS_DATA_AS_OF_UNAVAILABLE ? "本周期暂无 Actuals" : `Actuals 截至 ${actuals.data_as_of}`;
|
|
394
|
+
const quality = forecast.data_quality;
|
|
395
|
+
const qualityWidgets: DashboardWidget[] = !quality || quality.status === "complete" ? [] : [
|
|
396
|
+
{
|
|
397
|
+
id: "forecast-data-coverage",
|
|
398
|
+
type: "stat",
|
|
399
|
+
span: "full",
|
|
400
|
+
dataset: "forecast-data-coverage.json",
|
|
401
|
+
data: {
|
|
402
|
+
label: "预测数据覆盖率",
|
|
403
|
+
value: `${(quality.calculable_spend_pct * 100).toFixed(2)}%`,
|
|
404
|
+
description: `可计算预算 ${formatCurrency(quality.calculable_spend, forecast.reporting_currency, locale)} / 计划预算 ${formatCurrency(quality.planned_spend, forecast.reporting_currency, locale)}`,
|
|
405
|
+
footnote: forecast.conclusion?.warning ?? "异常切片未参与收入与 ROAS 计算,批准预算未被删除或重分配。",
|
|
406
|
+
progress: { fraction: quality.calculable_spend_pct, label: `${quality.issue_count} 个待修复切片`, tone: "warning" },
|
|
407
|
+
},
|
|
408
|
+
},
|
|
409
|
+
{
|
|
410
|
+
id: "forecast-data-issues",
|
|
411
|
+
type: "table",
|
|
412
|
+
span: "full",
|
|
413
|
+
dataset: "forecast-data-issues.json",
|
|
414
|
+
data: {
|
|
415
|
+
label: "数据待修复项",
|
|
416
|
+
description: "问题切片保持在批准计划中,但不参与预测计算、Actuals 比较或执行。",
|
|
417
|
+
columns: [
|
|
418
|
+
{ key: "slice", label: "App / 商店 / 渠道" },
|
|
419
|
+
{ key: "problem", label: "数据问题" },
|
|
420
|
+
{ key: "remediation", label: "修复建议" },
|
|
421
|
+
{ key: "owner", label: "负责人" },
|
|
422
|
+
],
|
|
423
|
+
rows: quality.issues.map((issue) => ({
|
|
424
|
+
slice: `${issue.slice.app_id} · ${issue.slice.store} · ${issue.slice.channel_group}`,
|
|
425
|
+
problem: issue.evidence.join("\n"),
|
|
426
|
+
remediation: issue.remediation,
|
|
427
|
+
owner: issue.owner_role,
|
|
428
|
+
})),
|
|
429
|
+
},
|
|
430
|
+
},
|
|
431
|
+
];
|
|
432
|
+
const generalLimits = forecast.unsupported_metrics.filter((item) => {
|
|
433
|
+
return !(item && typeof item === "object" && !Array.isArray(item)
|
|
434
|
+
&& (item as Record<string, unknown>).reason === "slice_key_not_in_actuals");
|
|
435
|
+
});
|
|
436
|
+
const limitWidgets: DashboardWidget[] = generalLimits.length === 0 ? [] : [{
|
|
437
|
+
id: "forecast-limitations",
|
|
438
|
+
type: "table",
|
|
439
|
+
span: "full",
|
|
440
|
+
dataset: "forecast-limitations.json",
|
|
441
|
+
data: {
|
|
442
|
+
label: "预测限制提醒",
|
|
443
|
+
description: "以下上游数据或指标限制未阻断预测;不支持的值保持为空,待数据工作人员修复。",
|
|
444
|
+
columns: [{ key: "item", label: "限制与修复信息" }],
|
|
445
|
+
rows: generalLimits.map((item) => ({ item: typeof item === "string" ? item : JSON.stringify(item) })),
|
|
446
|
+
},
|
|
447
|
+
}];
|
|
393
448
|
|
|
394
449
|
const widgets: DashboardWidget[] = [
|
|
450
|
+
...qualityWidgets,
|
|
451
|
+
...limitWidgets,
|
|
395
452
|
{
|
|
396
453
|
id: "revenue-vs-forecast",
|
|
397
454
|
type: "stat",
|
|
@@ -173,7 +173,18 @@ export async function buildDashboardProjection(
|
|
|
173
173
|
if (actuals.query_receipts.some((receipt) => receipt.dataset === "ua_spend.slices.discovery" && receipt.row_count >= 1000)) build.warnings.push("Unplanned-slice discovery reached its 1000-row safety limit; approved slices and like-for-like totals remain complete, but additional warnings may be omitted.");
|
|
174
174
|
if (actuals.data_as_of === "unavailable") build.warnings.push("No current-period UA Actuals are available.");
|
|
175
175
|
|
|
176
|
-
const
|
|
177
|
-
|
|
176
|
+
const detectedSliceKeyMismatch = detectSliceKeyMismatch(forecast.approved_allocation, actuals);
|
|
177
|
+
const qualityIssueKeys = new Set((forecast.data_quality?.issues ?? []).map((issue) => sliceKey(issue.slice)));
|
|
178
|
+
const qualityAcknowledgesMismatch = forecast.data_quality !== undefined
|
|
179
|
+
&& forecast.data_quality.status !== "complete"
|
|
180
|
+
&& forecast.approved_allocation
|
|
181
|
+
.filter((allocation) => allocation.approved_spend > 0)
|
|
182
|
+
.every((allocation) => qualityIssueKeys.has(sliceKey(allocation)));
|
|
183
|
+
const sliceKeyMismatch = detectedSliceKeyMismatch && !qualityAcknowledgesMismatch ? detectedSliceKeyMismatch : null;
|
|
184
|
+
if (detectedSliceKeyMismatch) {
|
|
185
|
+
build.warnings.push(qualityAcknowledgesMismatch
|
|
186
|
+
? `${detectedSliceKeyMismatch} The affected slices are already isolated by forecast data quality, so dashboard publication continues with limits.`
|
|
187
|
+
: detectedSliceKeyMismatch);
|
|
188
|
+
}
|
|
178
189
|
return { build, forecast, forecastRead, executionRead, actuals, sliceKeyMismatch, ...provenance };
|
|
179
190
|
}
|
|
@@ -245,6 +245,61 @@ export function projectStrategyModule(
|
|
|
245
245
|
|
|
246
246
|
export function projectForecastModule(forecast: ApprovedCycleForecast, artifactRef: ArtifactRefV2): DashboardModuleBuild {
|
|
247
247
|
const allocationByKey = new Map(forecast.approved_allocation.map((item) => [`${item.app_id}\u0000${item.store}\u0000${item.channel_group}`, item]));
|
|
248
|
+
const quality = forecast.data_quality;
|
|
249
|
+
const summary = quality?.status !== "complete" && forecast.conclusion
|
|
250
|
+
? forecast.conclusion.metrics
|
|
251
|
+
: forecast.consolidated_forecast;
|
|
252
|
+
const qualityWidgets: DashboardModuleBuild["widgets"] = !quality || quality.status === "complete" ? [] : [{
|
|
253
|
+
id: "forecast-data-coverage",
|
|
254
|
+
type: "stat",
|
|
255
|
+
span: "full",
|
|
256
|
+
dataset: "forecast-data-coverage.json",
|
|
257
|
+
data: {
|
|
258
|
+
label: "预测数据覆盖率",
|
|
259
|
+
value: `${(quality.calculable_spend_pct * 100).toFixed(2)}%`,
|
|
260
|
+
description: `可计算预算 ${quality.calculable_spend.toFixed(2)} / 计划预算 ${quality.planned_spend.toFixed(2)};异常切片 ${quality.issue_count} 个。`,
|
|
261
|
+
footnote: forecast.conclusion?.warning ?? "异常切片未参与收入与 ROAS 计算,批准预算未被删除或重分配。",
|
|
262
|
+
progress: { fraction: quality.calculable_spend_pct, label: `${(quality.calculable_spend_pct * 100).toFixed(2)}%`, tone: "warning" },
|
|
263
|
+
},
|
|
264
|
+
}];
|
|
265
|
+
const issueWidgets: DashboardModuleBuild["widgets"] = !quality || quality.issues.length === 0 ? [] : [{
|
|
266
|
+
id: "forecast-data-issues",
|
|
267
|
+
type: "table",
|
|
268
|
+
span: "full",
|
|
269
|
+
dataset: "forecast-data-issues.json",
|
|
270
|
+
data: {
|
|
271
|
+
label: "数据待修复项",
|
|
272
|
+
description: "这些问题不阻断预测结论,但对应切片不参与计算且不可执行。",
|
|
273
|
+
columns: [
|
|
274
|
+
{ key: "slice", label: "App / 商店 / 渠道" },
|
|
275
|
+
{ key: "problem", label: "问题" },
|
|
276
|
+
{ key: "remediation", label: "修复建议" },
|
|
277
|
+
{ key: "owner", label: "负责人" },
|
|
278
|
+
],
|
|
279
|
+
rows: quality.issues.map((issue) => ({
|
|
280
|
+
slice: `${issue.slice.app_id} · ${issue.slice.store} · ${issue.slice.channel_group}`,
|
|
281
|
+
problem: issue.evidence.join("\n"),
|
|
282
|
+
remediation: issue.remediation,
|
|
283
|
+
owner: issue.owner_role,
|
|
284
|
+
})),
|
|
285
|
+
},
|
|
286
|
+
}];
|
|
287
|
+
const generalLimits = forecast.unsupported_metrics.filter((item) => {
|
|
288
|
+
return !(item && typeof item === "object" && !Array.isArray(item)
|
|
289
|
+
&& (item as Record<string, unknown>).reason === "slice_key_not_in_actuals");
|
|
290
|
+
});
|
|
291
|
+
const limitWidgets: DashboardModuleBuild["widgets"] = generalLimits.length === 0 ? [] : [{
|
|
292
|
+
id: "forecast-limitations",
|
|
293
|
+
type: "table",
|
|
294
|
+
span: "full",
|
|
295
|
+
dataset: "forecast-limitations.json",
|
|
296
|
+
data: {
|
|
297
|
+
label: "预测限制提醒",
|
|
298
|
+
description: "以下上游数据或指标限制未阻断预测;不支持的值保持为空,待数据工作人员修复。",
|
|
299
|
+
columns: [{ key: "item", label: "限制与修复信息" }],
|
|
300
|
+
rows: generalLimits.map((item) => ({ item: typeof item === "string" ? item : JSON.stringify(item) })),
|
|
301
|
+
},
|
|
302
|
+
}];
|
|
248
303
|
return {
|
|
249
304
|
id: "next-forecast",
|
|
250
305
|
title: "下周期数据预测",
|
|
@@ -259,6 +314,7 @@ export function projectForecastModule(forecast: ApprovedCycleForecast, artifactR
|
|
|
259
314
|
data_as_of: forecast.data_as_of,
|
|
260
315
|
},
|
|
261
316
|
widgets: [
|
|
317
|
+
...qualityWidgets,
|
|
262
318
|
{
|
|
263
319
|
id: "forecast-summary",
|
|
264
320
|
type: "table",
|
|
@@ -267,13 +323,13 @@ export function projectForecastModule(forecast: ApprovedCycleForecast, artifactR
|
|
|
267
323
|
data: {
|
|
268
324
|
label: "预测摘要",
|
|
269
325
|
columns: [{ key: "metric", label: "指标" }, { key: "downside", label: "Downside", align: "right" }, { key: "base", label: "Base", align: "right" }, { key: "upside", label: "Upside", align: "right" }],
|
|
270
|
-
rows: Object.entries(
|
|
326
|
+
rows: Object.entries(summary).map(([metric, scenario]) => ({
|
|
271
327
|
metric,
|
|
272
328
|
downside: cell(scenario.downside),
|
|
273
329
|
base: cell(scenario.base),
|
|
274
330
|
upside: cell(scenario.upside),
|
|
275
331
|
})),
|
|
276
|
-
description: `${forecast.target_period.start_inclusive} → ${forecast.target_period.end_exclusive}`,
|
|
332
|
+
description: `${forecast.target_period.start_inclusive} → ${forecast.target_period.end_exclusive}${quality?.status !== "complete" ? " · 部分口径" : ""}`,
|
|
277
333
|
},
|
|
278
334
|
},
|
|
279
335
|
{
|
|
@@ -299,6 +355,8 @@ export function projectForecastModule(forecast: ApprovedCycleForecast, artifactR
|
|
|
299
355
|
}),
|
|
300
356
|
},
|
|
301
357
|
},
|
|
358
|
+
...issueWidgets,
|
|
359
|
+
...limitWidgets,
|
|
302
360
|
],
|
|
303
361
|
};
|
|
304
362
|
}
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"label": "报告预测阻断",
|
|
34
34
|
"outputKey": "forecast_blocked",
|
|
35
35
|
"parseJson": true,
|
|
36
|
-
"prompt": "
|
|
36
|
+
"prompt": "策略核验结果:{{data.confirmed_strategy}}\n预测计划修复结果:{{data.repair_forecast_plan}}\n\n汇总实际阻断原因。只输出 JSON:{\"kind\":\"fpa.graph-handoff\",\"status\":\"blocked\",\"graph\":\"fpa-forecast-freeze\",\"blockers\":[],\"required_action\":\"需要主会话处理的治理或策略动作\"}",
|
|
37
37
|
"systemPrompt": "用中文简洁报告预测阻断,只输出约定 JSON。",
|
|
38
38
|
"type": "prompt"
|
|
39
39
|
},
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"label": "正式预测决策计划",
|
|
44
44
|
"next": "compose_forecast",
|
|
45
45
|
"outputKey": "draft_forecast",
|
|
46
|
-
"prompt": "操作备注:{{goal}}\n\n已核验策略与决策:{{data.confirmed_strategy}}\n\n读取 reviewed_strategy_handoff、strategy_proposal、strategy_review 和精确 strategy_decision,按 fpa-forecast-approved-strategy 写 artifacts/forecast_plan.json。不得重新优化策略;业务分配、范围、假设或审核条件需变化时必须 blocked 并回到策略规划。只写 allocation 与每个切片的 ROAS assumptions;revenue、汇总、单位和 frozen_at 由下游 fpa_forecast_compose
|
|
46
|
+
"prompt": "操作备注:{{goal}}\n\n已核验策略与决策:{{data.confirmed_strategy}}\n\n读取 reviewed_strategy_handoff、strategy_proposal、strategy_review 和精确 strategy_decision,按 fpa-forecast-approved-strategy 写 artifacts/forecast_plan.json。不得重新优化策略;业务分配、范围、假设或审核条件需变化时必须 blocked 并回到策略规划。只写 allocation 与每个切片的 ROAS assumptions;revenue、汇总、单位和 frozen_at 由下游 fpa_forecast_compose 推导。优先使用 ua_spend 的 app_code/platform/media_source 真实组合;若已确认分配中的部分切片与当前上游映射不一致,不得擅自删除、置零、重分配或伪造映射,保留原切片交给 compose 自动产出 complete_with_limits、覆盖率和数据修复项。不得调用任何 fpa_dashboard_* 工具。",
|
|
47
47
|
"skills": ["fpa-apply-core-rules", "fpa-forecast-approved-strategy"],
|
|
48
48
|
"systemPrompt": "你是 FP&A 正式预测 Agent。只按已确认策略写预测计划,不请求审批、不发布仪表盘、不手算派生值。",
|
|
49
49
|
"tools": ["read", "write", "fpa_calc"],
|
|
@@ -63,14 +63,26 @@
|
|
|
63
63
|
"agentName": "repair_forecast_plan",
|
|
64
64
|
"id": "repair_forecast_plan",
|
|
65
65
|
"label": "定点修正预测计划",
|
|
66
|
-
"next": "
|
|
66
|
+
"next": "route_repair_forecast_plan",
|
|
67
67
|
"outputKey": "repair_forecast_plan",
|
|
68
|
-
"
|
|
68
|
+
"parseJson": true,
|
|
69
|
+
"prompt": "合成失败:{{data.__graphError}}\n\n读取 artifacts/forecast_plan.json、planning_brief 和 reviewed_strategy_handoff,只定点修正格式或 forecast plan 契约问题。不得通过删除、置零、重分配或伪造映射来修正数据质量问题;这类问题应由 compose 降级为 complete_with_limits。不得改变已确认策略的业务分配、范围、假设或条件;若错误要求此类变化,返回 blocked。不得调用任何 fpa_dashboard_* 工具。只输出 JSON:{\"status\":\"repaired|blocked\",\"changes\":[],\"blockers\":[]}",
|
|
69
70
|
"skills": ["fpa-forecast-approved-strategy"],
|
|
70
71
|
"systemPrompt": "你是预测计划修复 Agent,只按确定性合成报错做最小修正。",
|
|
71
72
|
"tools": ["read", "write", "edit"],
|
|
72
73
|
"type": "subagent"
|
|
73
74
|
},
|
|
75
|
+
{
|
|
76
|
+
"cases": [
|
|
77
|
+
{ "equals": "repaired", "label": "已修正契约", "to": "compose_forecast" },
|
|
78
|
+
{ "equals": "blocked", "label": "需要治理处理", "to": "report_blocked" }
|
|
79
|
+
],
|
|
80
|
+
"default": { "label": "其他结果", "to": "report_blocked" },
|
|
81
|
+
"id": "route_repair_forecast_plan",
|
|
82
|
+
"label": "判断能否重新合成",
|
|
83
|
+
"path": "data.repair_forecast_plan.status",
|
|
84
|
+
"type": "router"
|
|
85
|
+
},
|
|
74
86
|
{
|
|
75
87
|
"args": {
|
|
76
88
|
"artifact": "{{data.compose_forecast.details.artifact}}",
|
|
@@ -91,5 +103,5 @@
|
|
|
91
103
|
"schemaVersion": 1,
|
|
92
104
|
"start": "load_confirmed_strategy",
|
|
93
105
|
"transitionLabels": { "default": "其他情况", "error": "失败", "next": "继续" },
|
|
94
|
-
"version": "2.
|
|
106
|
+
"version": "2.2.0"
|
|
95
107
|
}
|
package/package.json
CHANGED
|
@@ -84,11 +84,23 @@ tool. `complete_with_limits` remains publishable when its limitations are
|
|
|
84
84
|
explicit, while `blocked` is not. Do not hide a real defect to get through the
|
|
85
85
|
gate.
|
|
86
86
|
|
|
87
|
+
Upstream identity or coverage defects are non-blocking data limits. Preserve
|
|
88
|
+
the full approved allocation, leave affected revenue and ROAS values `NULL`,
|
|
89
|
+
and let `fpa_forecast_compose` emit `data_quality`, a coverage-labelled partial
|
|
90
|
+
`conclusion`, and actionable repair items. Never delete a bad slice, turn it
|
|
91
|
+
into zero spend, or renormalize the remaining allocation. The affected slice
|
|
92
|
+
remains ineligible for Actuals comparison and execution until its mapping is
|
|
93
|
+
fixed. Approval mismatch, duplicate or negative allocation, budget mismatch,
|
|
94
|
+
invalid period/currency/role, and unauthorized execution remain blocking
|
|
95
|
+
governance failures.
|
|
96
|
+
|
|
87
97
|
## Boundaries
|
|
88
98
|
|
|
89
99
|
- Do not optimize, revise, or substitute the approved strategy while forecasting.
|
|
90
100
|
- A required change invalidates the current approval and routes back to recommendation and review.
|
|
91
101
|
- Unsupported KPIs remain `NULL` with a reason.
|
|
102
|
+
- A partial conclusion must say it covers only calculable slices and must show
|
|
103
|
+
`calculable_spend_pct`; it must never be labelled portfolio ROAS.
|
|
92
104
|
- Do not execute the strategy and do not start continuous monitoring.
|
|
93
105
|
|
|
94
106
|
## Completion
|
|
@@ -41,6 +41,28 @@ calibration_policy:
|
|
|
41
41
|
policy_version: string
|
|
42
42
|
unsupported_metrics: []
|
|
43
43
|
reconciliation_checks: []
|
|
44
|
+
data_quality:
|
|
45
|
+
status: complete | partial | unavailable
|
|
46
|
+
planned_spend: number
|
|
47
|
+
calculable_spend: number
|
|
48
|
+
calculable_spend_pct: number # 0..1; never renormalized
|
|
49
|
+
excluded_spend: number
|
|
50
|
+
issue_count: number
|
|
51
|
+
issues:
|
|
52
|
+
- issue_id: string
|
|
53
|
+
code: slice_key_not_in_actuals
|
|
54
|
+
severity: warning
|
|
55
|
+
slice: {app_id: string, store: string, channel_group: string}
|
|
56
|
+
affected_metrics: [actuals_comparison, revenue, roas, execution]
|
|
57
|
+
disposition: excluded_from_calculation
|
|
58
|
+
evidence: []
|
|
59
|
+
remediation: string
|
|
60
|
+
owner_role: data_steward
|
|
61
|
+
conclusion:
|
|
62
|
+
status: available | available_with_limits | unavailable
|
|
63
|
+
scope: portfolio | calculable_slices | none
|
|
64
|
+
metrics: {spend: scenario_metric, revenue: scenario_metric, roas: scenario_metric}
|
|
65
|
+
warning: string | null
|
|
44
66
|
frozen_at: timestamp
|
|
45
67
|
```
|
|
46
68
|
|
|
@@ -97,7 +119,10 @@ Rules the plan has to respect, because compose enforces them:
|
|
|
97
119
|
|
|
98
120
|
- **Spend is a decision, not a prediction.** It is identical in all three scenarios, so a slice carries one `approved_spend`. Only revenue moves, through `roas`.
|
|
99
121
|
- **A stopped slice has no ROAS.** `approved_spend: 0` means revenue 0 and `roas` null in all three scenarios — the contract derives ROAS as revenue/spend and calls a zero denominator null.
|
|
100
|
-
- **Slice keys
|
|
122
|
+
- **Slice keys are checked as real triples.** `app_code`, `platform`, and `media_source` must occur together in `ua_spend`; validating three independent value lists is insufficient. A mismatch is isolated as a data-quality limit: approved spend remains in the allocation and consolidated spend, while affected revenue/ROAS are `NULL`. The single-placeholder App axis for a scoped-out portfolio remains permitted.
|
|
123
|
+
- **Do not hide partiality.** Compose reports planned, calculable, and excluded spend. Its partial conclusion covers only calculable slices; remaining spend is never scaled to 100%, and partial ROAS is never labelled portfolio ROAS.
|
|
124
|
+
- **Data quality does not weaken governance.** Missing/mismatched approval, duplicate or negative allocation, invalid period/currency/forecast role, and budget reconciliation failures still block.
|
|
125
|
+
- **Describe other upstream limits actionably.** For non-identity defects, each `unsupported_metrics` item should name the affected metric/scope, evidence, remediation, and data owner. Dashboard publication keeps these items visible even when `data_quality.status` is otherwise complete.
|
|
101
126
|
- **`status` and `unsupported_metrics` must agree.** `complete` requires an empty list; `complete_with_limits` requires a non-empty one. Scope excluded upstream is neither.
|
|
102
127
|
- **Do not hand-compute anything derived.** A ROAS rounded to four decimals misses the commit tolerance and costs a re-draft.
|
|
103
128
|
|