@viccydev/pi-fpa 0.3.0 → 0.3.1
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.
|
@@ -4,7 +4,8 @@ import type { Period } from "../fpa-artifacts/contracts.ts";
|
|
|
4
4
|
import { toNumber, type NumericLike } from "../fpa-data/calc.ts";
|
|
5
5
|
import { runStructuredQuery } from "../fpa-data/runtime.ts";
|
|
6
6
|
import type { SqlRow } from "../fpa-data/supabase.ts";
|
|
7
|
-
import type { DashboardActualsSnapshot, DashboardDailyPoint, QueryReceipt } from "./source.ts";
|
|
7
|
+
import type { DashboardActualsSnapshot, DashboardDailyPoint, DashboardScopeMode, QueryReceipt } from "./source.ts";
|
|
8
|
+
import { ACTUALS_DATA_AS_OF_UNAVAILABLE } from "./source.ts";
|
|
8
9
|
|
|
9
10
|
function dateInTimezone(timestamp: string, timezone: string, label: string): string {
|
|
10
11
|
const instant = new Date(timestamp);
|
|
@@ -96,11 +97,16 @@ export interface DashboardActualsScope {
|
|
|
96
97
|
slices: Array<{ app_id: string; store: string; channel_group: string }>;
|
|
97
98
|
}
|
|
98
99
|
|
|
99
|
-
|
|
100
|
+
/**
|
|
101
|
+
* Rows carry app_code only in "app" scope mode. In "portfolio" mode the App axis was
|
|
102
|
+
* collapsed onto a placeholder before the query ran, so the caller supplies it back.
|
|
103
|
+
*/
|
|
104
|
+
export function mapDashboardSlices(rows: SqlRow[], portfolioAppId?: string) {
|
|
100
105
|
return rows
|
|
101
|
-
.filter((row) => row.
|
|
106
|
+
.filter((row) => row.platform != null && row.media_source != null)
|
|
107
|
+
.filter((row) => portfolioAppId !== undefined || row.app_code != null)
|
|
102
108
|
.map((row) => ({
|
|
103
|
-
app_id: String(row.app_code),
|
|
109
|
+
app_id: portfolioAppId ?? String(row.app_code),
|
|
104
110
|
store: String(row.platform),
|
|
105
111
|
channel_group: String(row.media_source),
|
|
106
112
|
spend: value(row, "spend"),
|
|
@@ -109,6 +115,35 @@ export function mapDashboardSlices(rows: SqlRow[]) {
|
|
|
109
115
|
}));
|
|
110
116
|
}
|
|
111
117
|
|
|
118
|
+
/**
|
|
119
|
+
* The App axis carries no information when every approved slice shares one app_id.
|
|
120
|
+
* That is the only case where dropping the app_code predicate can be considered.
|
|
121
|
+
*/
|
|
122
|
+
export function collapsedAppId(slices: DashboardActualsScope["slices"]): string | null {
|
|
123
|
+
const appIds = new Set(slices.map((slice) => slice.app_id));
|
|
124
|
+
return appIds.size === 1 ? (slices[0]?.app_id ?? null) : null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** True when the value is never used as an app_code in ua_spend, i.e. it is a placeholder. */
|
|
128
|
+
async function isPlaceholderAppId(appId: string, signal?: AbortSignal): Promise<boolean> {
|
|
129
|
+
const probe = await runStructuredQuery(
|
|
130
|
+
{ dataset: "ua_spend", metrics: ["spend"], dimensions: ["app_code"], filters: { app_code: appId }, limit: 1 },
|
|
131
|
+
signal,
|
|
132
|
+
);
|
|
133
|
+
return probe.rows.length === 0;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function resolveScopeMode(
|
|
137
|
+
slices: DashboardActualsScope["slices"],
|
|
138
|
+
signal?: AbortSignal,
|
|
139
|
+
): Promise<{ mode: DashboardScopeMode; portfolioAppId: string | null }> {
|
|
140
|
+
const collapsed = collapsedAppId(slices);
|
|
141
|
+
if (collapsed === null) return { mode: "app", portfolioAppId: null };
|
|
142
|
+
// A single real app still scopes on app_code; only a value absent from the mart is a placeholder.
|
|
143
|
+
if (!(await isPlaceholderAppId(collapsed, signal))) return { mode: "app", portfolioAppId: null };
|
|
144
|
+
return { mode: "portfolio", portfolioAppId: collapsed };
|
|
145
|
+
}
|
|
146
|
+
|
|
112
147
|
export async function loadDashboardActuals(
|
|
113
148
|
period: Period,
|
|
114
149
|
scope: DashboardActualsScope,
|
|
@@ -123,30 +158,41 @@ export async function loadDashboardActuals(
|
|
|
123
158
|
};
|
|
124
159
|
const exactUaScope = uniqueSlices.map((slice) => ({ app_code: slice.app_id, platform: slice.store, media_source: slice.channel_group }));
|
|
125
160
|
const exactUaPortfolioScope = [...new Map(uniqueSlices.map((slice) => [`${slice.app_id}\u0000${slice.store}`, { app_code: slice.app_id, platform: slice.store }])).values()];
|
|
126
|
-
const
|
|
161
|
+
const { mode, portfolioAppId } = await resolveScopeMode(uniqueSlices, signal);
|
|
162
|
+
const portfolio = mode === "portfolio";
|
|
163
|
+
// In portfolio mode the App axis is a placeholder absent from ua_spend, so scoping on
|
|
164
|
+
// app_code would match nothing. Drop it and aggregate every app at platform x media_source.
|
|
165
|
+
const sliceScope = portfolio
|
|
166
|
+
? { exactUaChannelScope: [...new Map(uniqueSlices.map((slice) => [`${slice.store}\u0000${slice.channel_group}`, { platform: slice.store, media_source: slice.channel_group }])).values()] }
|
|
167
|
+
: { exactUaScope };
|
|
168
|
+
const discoveryScope = portfolio
|
|
169
|
+
? { filters: { platform: [...new Set(uniqueSlices.map((slice) => slice.store))] } }
|
|
170
|
+
: { exactUaPortfolioScope };
|
|
171
|
+
const sliceDimensions = portfolio ? ["platform", "media_source"] : ["app_code", "platform", "media_source"];
|
|
172
|
+
const current = await runStructuredQuery({ ...common, ...sliceScope, dateFrom: dates.currentFrom, dateTo: dates.currentTo, limit: 1 }, signal);
|
|
127
173
|
const currentRow = current.rows[0];
|
|
128
174
|
const coverageMin = currentRow?.date_min == null ? null : String(currentRow.date_min);
|
|
129
175
|
const coverageMax = currentRow?.date_max == null ? null : String(currentRow.date_max);
|
|
130
176
|
const comparisonDates = coverageMax ? comparisonDatesForCoverage(dates.currentFrom, coverageMax) : null;
|
|
131
177
|
const [comparison, daily, approvedSlices, discoveredSlices] = await Promise.all([
|
|
132
178
|
comparisonDates
|
|
133
|
-
? runStructuredQuery({ ...common,
|
|
179
|
+
? runStructuredQuery({ ...common, ...sliceScope, dateFrom: comparisonDates.comparisonFrom, dateTo: comparisonDates.comparisonTo, limit: 1 }, signal)
|
|
134
180
|
: Promise.resolve(null),
|
|
135
|
-
runStructuredQuery({ ...common,
|
|
181
|
+
runStructuredQuery({ ...common, ...sliceScope, dateFrom: dates.currentFrom, dateTo: dates.currentTo, timeGrain: "day", limit: 1000 }, signal),
|
|
136
182
|
runStructuredQuery({
|
|
137
183
|
...common,
|
|
138
184
|
dateFrom: dates.currentFrom,
|
|
139
185
|
dateTo: dates.currentTo,
|
|
140
|
-
|
|
141
|
-
dimensions:
|
|
186
|
+
...sliceScope,
|
|
187
|
+
dimensions: sliceDimensions,
|
|
142
188
|
limit: 500,
|
|
143
189
|
}, signal),
|
|
144
190
|
runStructuredQuery({
|
|
145
191
|
...common,
|
|
146
192
|
dateFrom: dates.currentFrom,
|
|
147
193
|
dateTo: dates.currentTo,
|
|
148
|
-
|
|
149
|
-
dimensions:
|
|
194
|
+
...discoveryScope,
|
|
195
|
+
dimensions: sliceDimensions,
|
|
150
196
|
limit: 1000,
|
|
151
197
|
}, signal),
|
|
152
198
|
]);
|
|
@@ -159,16 +205,23 @@ export async function loadDashboardActuals(
|
|
|
159
205
|
receipt("ua_spend.slices.approved", approvedSlices.built.sql, approvedSlices.rows.length),
|
|
160
206
|
receipt("ua_spend.slices.discovery", discoveredSlices.built.sql, discoveredSlices.rows.length),
|
|
161
207
|
];
|
|
162
|
-
const approvedKeys = new Set(
|
|
208
|
+
const approvedKeys = new Set(
|
|
209
|
+
portfolio
|
|
210
|
+
? uniqueSlices.map((slice) => `${slice.store}\u0000${slice.channel_group}`)
|
|
211
|
+
: exactUaScope.map((scope) => `${scope.app_code}\u0000${scope.platform}\u0000${scope.media_source}`),
|
|
212
|
+
);
|
|
213
|
+
const rowKey = (row: SqlRow): string =>
|
|
214
|
+
portfolio ? `${row.platform}\u0000${row.media_source}` : `${row.app_code}\u0000${row.platform}\u0000${row.media_source}`;
|
|
163
215
|
const sliceRows = [
|
|
164
216
|
...approvedSlices.rows,
|
|
165
|
-
...discoveredSlices.rows.filter((row) => !approvedKeys.has(
|
|
217
|
+
...discoveredSlices.rows.filter((row) => !approvedKeys.has(rowKey(row))),
|
|
166
218
|
];
|
|
167
219
|
|
|
168
220
|
return {
|
|
169
221
|
period,
|
|
170
|
-
data_as_of: coverageMax ??
|
|
222
|
+
data_as_of: coverageMax ?? ACTUALS_DATA_AS_OF_UNAVAILABLE,
|
|
171
223
|
reporting_currency: "USD",
|
|
224
|
+
scope_mode: mode,
|
|
172
225
|
coverage: {
|
|
173
226
|
date_min: coverageMin,
|
|
174
227
|
date_max: coverageMax,
|
|
@@ -179,7 +232,7 @@ export async function loadDashboardActuals(
|
|
|
179
232
|
? { spend: value(comparisonRow, "spend"), revenue: value(comparisonRow, "revenue") }
|
|
180
233
|
: null,
|
|
181
234
|
daily: completeDailySeries(daily.rows, dates.currentFrom, coverageMax),
|
|
182
|
-
slices: mapDashboardSlices(sliceRows),
|
|
235
|
+
slices: mapDashboardSlices(sliceRows, portfolioAppId ?? undefined),
|
|
183
236
|
query_receipts: queryReceipts,
|
|
184
237
|
};
|
|
185
238
|
}
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
type ForecastAllocation,
|
|
8
8
|
type ForecastSlice,
|
|
9
9
|
} from "../fpa-artifacts/contracts.ts";
|
|
10
|
-
import { validateActualsSnapshot, type DashboardActualsSnapshot, type DashboardActualSlice } from "./source.ts";
|
|
10
|
+
import { validateActualsSnapshot, ACTUALS_DATA_AS_OF_UNAVAILABLE, type DashboardActualsSnapshot, type DashboardActualSlice } from "./source.ts";
|
|
11
11
|
|
|
12
12
|
export type Tone = "positive" | "negative" | "warning" | "neutral";
|
|
13
13
|
export type TableCell = string | null | { value: string | null; tone?: Tone };
|
|
@@ -123,6 +123,29 @@ function formatRoas(value: number | null): string | null {
|
|
|
123
123
|
return value === null ? null : value.toFixed(2);
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
/** Shown when a ratio is undefined by construction rather than missing from the data. */
|
|
127
|
+
const NOT_APPLICABLE = "—";
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* ROAS over a zero budget is undefined, not missing. Rendering it as "no data" makes a
|
|
131
|
+
* deliberately stopped channel look like a broken join, so the two are kept distinct.
|
|
132
|
+
*/
|
|
133
|
+
function roasCell(roas: number | null, spend: number | null, tone?: Tone): TableCell {
|
|
134
|
+
if (roas === null && spend === 0) return NOT_APPLICABLE;
|
|
135
|
+
return numericCell(formatRoas(roas), tone);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** A slice with no planned budget has nothing to deviate from. */
|
|
139
|
+
function deviationCell(deviation: number | null, forecastSpend: number | null, tone?: Tone): TableCell {
|
|
140
|
+
if (deviation === null && forecastSpend === 0) return NOT_APPLICABLE;
|
|
141
|
+
return numericCell(formatSignedPercent(deviation), tone);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Rows are channel × store; the store is what disambiguates a channel run on both platforms. */
|
|
145
|
+
function sliceUnitLabel(allocation: ForecastAllocation): string {
|
|
146
|
+
return `${allocation.channel_group} · ${allocation.store}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
126
149
|
function formatSignedPercent(value: number | null, suffix = ""): string | null {
|
|
127
150
|
if (value === null) return null;
|
|
128
151
|
const sign = value > 0 ? "+" : "";
|
|
@@ -211,8 +234,9 @@ function buildStrategyTable(
|
|
|
211
234
|
): GroupedTableDataset {
|
|
212
235
|
const grouped = new Map<string, JoinedSlice[]>();
|
|
213
236
|
for (const slice of joined) grouped.set(slice.allocation.app_id, [...(grouped.get(slice.allocation.app_id) ?? []), slice]);
|
|
237
|
+
const appAxisIsInformative = grouped.size > 1;
|
|
214
238
|
const columns = [
|
|
215
|
-
{ key: "unit", label: "App / 渠道" },
|
|
239
|
+
{ key: "unit", label: appAxisIsInformative ? "App / 渠道 · 平台" : "渠道 · 平台" },
|
|
216
240
|
{ key: "forecast", label: "预测 ROAS", align: "right" as const },
|
|
217
241
|
{ key: "actual", label: "实际 ROAS", align: "right" as const },
|
|
218
242
|
{ key: "deviation", label: "偏差", align: "right" as const },
|
|
@@ -220,7 +244,7 @@ function buildStrategyTable(
|
|
|
220
244
|
{ key: "exec", label: "执行状态" },
|
|
221
245
|
];
|
|
222
246
|
return {
|
|
223
|
-
label:
|
|
247
|
+
label: `预测 vs 实际 · 执行策略(按${appAxisIsInformative ? " App × 渠道" : "渠道 · 平台"})`,
|
|
224
248
|
description:
|
|
225
249
|
`同周期付费 ROAS;偏差绝对值 ≥ ${(forecast.calibration_policy.deviation_warning_abs_gte * 100).toFixed(0)}% 标黄,` +
|
|
226
250
|
`> ${(forecast.calibration_policy.deviation_trigger_abs_gt * 100).toFixed(0)}% 触发校准。`,
|
|
@@ -249,20 +273,21 @@ function buildStrategyTable(
|
|
|
249
273
|
key: appId,
|
|
250
274
|
label: appId,
|
|
251
275
|
summary: {
|
|
252
|
-
forecast:
|
|
253
|
-
actual:
|
|
254
|
-
deviation:
|
|
276
|
+
forecast: roasCell(appForecast, forecastSpend),
|
|
277
|
+
actual: roasCell(appActual, actualSpend),
|
|
278
|
+
deviation: deviationCell(appDeviation, forecastSpend, appDeviation !== null && Math.abs(appDeviation) >= forecast.calibration_policy.deviation_warning_abs_gte ? "warning" : undefined),
|
|
255
279
|
decision: summaryTone ? { value: summaryAction, tone: summaryTone } : summaryAction,
|
|
256
280
|
exec: executionGroupLabel(executionReceipt, slices),
|
|
257
281
|
},
|
|
258
282
|
rows: slices.map((slice) => {
|
|
259
283
|
const stop = slice.actualRoas !== null && slice.actualRoas < forecast.calibration_policy.stop_loss_roas_lt;
|
|
260
284
|
const warn = slice.deviation !== null && Math.abs(slice.deviation) >= forecast.calibration_policy.deviation_warning_abs_gte;
|
|
285
|
+
const plannedSpend = slice.forecast.metrics.spend?.base ?? null;
|
|
261
286
|
return {
|
|
262
|
-
unit: slice.allocation
|
|
263
|
-
forecast:
|
|
264
|
-
actual:
|
|
265
|
-
deviation:
|
|
287
|
+
unit: sliceUnitLabel(slice.allocation),
|
|
288
|
+
forecast: roasCell(slice.forecastRoas, plannedSpend),
|
|
289
|
+
actual: roasCell(slice.actualRoas, slice.actual?.spend ?? null, stop ? "negative" : undefined),
|
|
290
|
+
deviation: deviationCell(slice.deviation, plannedSpend, stop ? "negative" : warn ? "warning" : undefined),
|
|
266
291
|
decision: actionCell(slice.allocation.action),
|
|
267
292
|
exec: executionLabel(executionReceipt, slice.execution),
|
|
268
293
|
};
|
|
@@ -276,7 +301,7 @@ function buildAlerts(joined: JoinedSlice[], forecast: ApprovedCycleForecastInput
|
|
|
276
301
|
const rows: Array<Record<string, TableCell>> = [];
|
|
277
302
|
for (const slice of joined) {
|
|
278
303
|
if (slice.actualRoas === null) continue;
|
|
279
|
-
const unit = `${slice.allocation.app_id} × ${slice.allocation
|
|
304
|
+
const unit = `${slice.allocation.app_id} × ${sliceUnitLabel(slice.allocation)}`;
|
|
280
305
|
if (slice.actualRoas < forecast.calibration_policy.stop_loss_roas_lt) {
|
|
281
306
|
rows.push({
|
|
282
307
|
level: { value: "严重", tone: "negative" },
|
|
@@ -344,6 +369,7 @@ export function projectDashboard(input: ProjectionInput): DashboardBuild {
|
|
|
344
369
|
const stopCount = joined.filter((slice) => slice.actualRoas !== null && slice.actualRoas < forecast.calibration_policy.stop_loss_roas_lt).length;
|
|
345
370
|
const deviationCount = alerts.rows.length - stopCount;
|
|
346
371
|
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)`;
|
|
372
|
+
const asOfNote = actuals.data_as_of === ACTUALS_DATA_AS_OF_UNAVAILABLE ? "本周期暂无 Actuals" : `Actuals 截至 ${actuals.data_as_of}`;
|
|
347
373
|
|
|
348
374
|
const widgets: DashboardWidget[] = [
|
|
349
375
|
{
|
|
@@ -366,7 +392,7 @@ export function projectDashboard(input: ProjectionInput): DashboardBuild {
|
|
|
366
392
|
progress: { fraction: clampFraction(revenueAttainment), label: formatSignedPercent(revenueAttainment)?.replace(/^\+/, "") ?? "无数据", tone: progressTone(revenueAttainment) },
|
|
367
393
|
description: `冻结预测 ${formatCurrency(forecastRevenue, forecast.reporting_currency, locale)}`,
|
|
368
394
|
}),
|
|
369
|
-
footnote: `${window}
|
|
395
|
+
footnote: `${window};${asOfNote}`,
|
|
370
396
|
},
|
|
371
397
|
},
|
|
372
398
|
{
|
|
@@ -474,6 +500,13 @@ export function projectDashboard(input: ProjectionInput): DashboardBuild {
|
|
|
474
500
|
warnings: [
|
|
475
501
|
...(forecast.status !== "complete" ? [`Approved forecast status is ${forecast.status}.`] : []),
|
|
476
502
|
...(!forecast.approval_conditions_satisfied ? ["Approved forecast conditions are not satisfied."] : []),
|
|
503
|
+
...(actuals.scope_mode === "portfolio"
|
|
504
|
+
? [
|
|
505
|
+
`App axis "${forecast.approved_allocation[0]?.app_id}" is not an app_code in ua_spend; ` +
|
|
506
|
+
"Actuals were aggregated across every app at the store × channel grain. " +
|
|
507
|
+
"Verify this matches the declared forecast scope before reading App-level conclusions into it.",
|
|
508
|
+
]
|
|
509
|
+
: []),
|
|
477
510
|
...(execution?.verification_status === "reported" ? ["Execution is reported and not externally verified."] : []),
|
|
478
511
|
...(missingExecutionSlices > 0 ? [`Execution receipt is missing ${missingExecutionSlices} approved slices.`] : []),
|
|
479
512
|
...(extraExecutionSlices > 0 ? [`Execution receipt contains ${extraExecutionSlices} slices outside the approved allocation.`] : []),
|
|
@@ -15,6 +15,19 @@ export interface DashboardActualSlice {
|
|
|
15
15
|
source_rows: number;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/** Sentinel used when no Actuals row exists yet, so no calendar date can be reported. */
|
|
19
|
+
export const ACTUALS_DATA_AS_OF_UNAVAILABLE = "unavailable";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* How Actuals were scoped against the frozen forecast.
|
|
23
|
+
*
|
|
24
|
+
* "app" - every slice matched ua_spend on app_code × platform × media_source.
|
|
25
|
+
* "portfolio" - the forecast collapsed the App axis onto a placeholder that does not
|
|
26
|
+
* exist in ua_spend, so Actuals were aggregated across every app at the
|
|
27
|
+
* platform × media_source grain.
|
|
28
|
+
*/
|
|
29
|
+
export type DashboardScopeMode = "app" | "portfolio";
|
|
30
|
+
|
|
18
31
|
export interface DashboardDailyPoint {
|
|
19
32
|
date: string;
|
|
20
33
|
spend: number | null;
|
|
@@ -25,6 +38,7 @@ export interface DashboardActualsSnapshot {
|
|
|
25
38
|
period: Period;
|
|
26
39
|
data_as_of: string;
|
|
27
40
|
reporting_currency: string;
|
|
41
|
+
scope_mode: DashboardScopeMode;
|
|
28
42
|
coverage: {
|
|
29
43
|
date_min: string | null;
|
|
30
44
|
date_max: string | null;
|
|
@@ -127,10 +141,13 @@ export function validateActualsSnapshot(value: unknown): DashboardActualsSnapsho
|
|
|
127
141
|
|
|
128
142
|
const reportingCurrency = string(source.reporting_currency, "actuals.reporting_currency").toUpperCase();
|
|
129
143
|
if (!/^[A-Z]{3}$/.test(reportingCurrency)) throw new Error("actuals.reporting_currency must be an ISO-4217 currency code.");
|
|
144
|
+
const scopeMode = source.scope_mode === undefined ? "app" : string(source.scope_mode, "actuals.scope_mode");
|
|
145
|
+
if (scopeMode !== "app" && scopeMode !== "portfolio") throw new Error('actuals.scope_mode must be "app" or "portfolio".');
|
|
130
146
|
return {
|
|
131
147
|
period: period(source.period, "actuals.period"),
|
|
132
148
|
data_as_of: string(source.data_as_of, "actuals.data_as_of"),
|
|
133
149
|
reporting_currency: reportingCurrency,
|
|
150
|
+
scope_mode: scopeMode,
|
|
134
151
|
coverage: {
|
|
135
152
|
date_min: coverage.date_min === null ? null : string(coverage.date_min, "actuals.coverage.date_min"),
|
|
136
153
|
date_max: coverage.date_max === null ? null : string(coverage.date_max, "actuals.coverage.date_max"),
|
|
@@ -27,6 +27,12 @@ export interface QuerySpec {
|
|
|
27
27
|
exactUaScope?: Array<{ app_code: string; platform: string; media_source: string }>;
|
|
28
28
|
/** Internal exact App + Store portfolio scope used to discover unplanned paid channels. */
|
|
29
29
|
exactUaPortfolioScope?: Array<{ app_code: string; platform: string }>;
|
|
30
|
+
/**
|
|
31
|
+
* Internal exact Store + Channel scope for forecasts whose App axis is a portfolio
|
|
32
|
+
* placeholder. The placeholder never matches ua_spend.app_code, so the App predicate
|
|
33
|
+
* is dropped and Actuals are aggregated across every app.
|
|
34
|
+
*/
|
|
35
|
+
exactUaChannelScope?: Array<{ platform: string; media_source: string }>;
|
|
30
36
|
sort?: { by: string; direction?: "asc" | "desc" };
|
|
31
37
|
limit?: number;
|
|
32
38
|
}
|
|
@@ -233,6 +239,20 @@ export function buildQuery(spec: QuerySpec): BuiltQuery {
|
|
|
233
239
|
filterKeys.push("app_code", "platform");
|
|
234
240
|
notes.push("Applied exact App + Store portfolio scope.");
|
|
235
241
|
}
|
|
242
|
+
if (spec.exactUaChannelScope !== undefined) {
|
|
243
|
+
if (dataset.id !== "ua_spend") throw new Error("exactUaChannelScope is supported only for ua_spend.");
|
|
244
|
+
if (spec.exactUaChannelScope.length === 0 || spec.exactUaChannelScope.length > 500) throw new Error("exactUaChannelScope must contain between 1 and 500 Store + Channel pairs.");
|
|
245
|
+
const dimensionSql = Object.fromEntries(dataset.dimensions.map((dimension) => [dimension.name, dimension.sql]));
|
|
246
|
+
const predicates = spec.exactUaChannelScope.map((scope, index) => {
|
|
247
|
+
for (const [name, value] of Object.entries(scope)) {
|
|
248
|
+
if (typeof value !== "string" || value.trim() === "") throw new Error(`exactUaChannelScope[${index}].${name} must be a non-empty string.`);
|
|
249
|
+
}
|
|
250
|
+
return `(${dimensionSql.platform} = ${escapeLiteral(scope.platform)} and ${dimensionSql.media_source} = ${escapeLiteral(scope.media_source)})`;
|
|
251
|
+
});
|
|
252
|
+
where.push(`(${predicates.join(" or ")})`);
|
|
253
|
+
filterKeys.push("platform", "media_source");
|
|
254
|
+
notes.push("Applied exact Store + Channel scope across every app (portfolio-placeholder forecast).");
|
|
255
|
+
}
|
|
236
256
|
|
|
237
257
|
const breakdown = resolveBreakdown(dataset, dimensionNames, filterKeys);
|
|
238
258
|
if (breakdown.predicate) {
|