@viccydev/pi-fpa 0.4.1 → 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.
- package/README.md +21 -4
- package/bin/fpa-dashboard-worker.mjs +98 -0
- package/extensions/fpa-artifacts/compose.ts +417 -0
- package/extensions/fpa-artifacts/contracts.ts +9 -1
- package/extensions/fpa-artifacts/index.ts +119 -4
- package/extensions/fpa-artifacts/store.ts +376 -50
- package/extensions/fpa-dashboard/actuals.ts +243 -40
- package/extensions/fpa-dashboard/coordinator.ts +831 -0
- package/extensions/fpa-dashboard/cycle-operating-projection.ts +559 -0
- package/extensions/fpa-dashboard/forecast-accuracy.ts +252 -0
- package/extensions/fpa-dashboard/forward-outlook.ts +142 -0
- package/extensions/fpa-dashboard/index.ts +96 -72
- package/extensions/fpa-dashboard/projector.ts +19 -0
- package/extensions/fpa-dashboard/provenance.ts +147 -0
- package/extensions/fpa-dashboard/publisher.ts +99 -9
- package/extensions/fpa-dashboard/service.ts +179 -0
- package/extensions/fpa-dashboard/source.ts +136 -1
- package/extensions/fpa-dashboard/status.ts +56 -5
- package/package.json +14 -4
- package/skills/fpa-execute-approved-strategy/SKILL.md +1 -1
- package/skills/fpa-forecast-approved-strategy/SKILL.md +22 -12
- package/skills/fpa-forecast-approved-strategy/references/artifact-contract.md +55 -4
- package/skills/fpa-refresh-dashboard/SKILL.md +20 -1
|
@@ -1,11 +1,84 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { lstat, open, realpath, stat } from "node:fs/promises";
|
|
4
|
+
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
2
5
|
|
|
3
6
|
import type { Period } from "../fpa-artifacts/contracts.ts";
|
|
7
|
+
import { sliceKey } from "../fpa-artifacts/contracts.ts";
|
|
8
|
+
import { stableJson } from "../fpa-artifacts/store.ts";
|
|
4
9
|
import { toNumber, type NumericLike } from "../fpa-data/calc.ts";
|
|
5
|
-
import { runStructuredQuery } from "../fpa-data/runtime.ts";
|
|
6
|
-
import type
|
|
10
|
+
import { runStructuredQuery, shapeQueryRows } from "../fpa-data/runtime.ts";
|
|
11
|
+
import { buildQuery, type QuerySpec } from "../fpa-data/sql.ts";
|
|
12
|
+
import { runQuery, type SqlRow } from "../fpa-data/supabase.ts";
|
|
7
13
|
import type { DashboardActualsSnapshot, DashboardDailyPoint, DashboardScopeMode, QueryReceipt } from "./source.ts";
|
|
8
|
-
import { ACTUALS_DATA_AS_OF_UNAVAILABLE } from "./source.ts";
|
|
14
|
+
import { ACTUALS_DATA_AS_OF_UNAVAILABLE, validateActualsSnapshot } from "./source.ts";
|
|
15
|
+
|
|
16
|
+
export interface ActualsSourceCloseSignal {
|
|
17
|
+
kind: "fpa.actuals.source-close";
|
|
18
|
+
schema_version: 1;
|
|
19
|
+
dataset: "ua_spend";
|
|
20
|
+
signal_id: string;
|
|
21
|
+
closed_through: string;
|
|
22
|
+
emitted_at: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function validateSourceCloseSignal(value: unknown): (ActualsSourceCloseSignal & { signal_sha256: string }) | null {
|
|
26
|
+
if (value === undefined || value === null) return null;
|
|
27
|
+
if (typeof value !== "object" || Array.isArray(value)) throw new Error("Actuals source close signal must be an object.");
|
|
28
|
+
const source = value as Record<string, unknown>;
|
|
29
|
+
if (source.kind !== "fpa.actuals.source-close" || source.schema_version !== 1) throw new Error("Actuals source close signal has an unsupported contract.");
|
|
30
|
+
if (source.dataset !== "ua_spend") throw new Error("Actuals source close signal must identify ua_spend.");
|
|
31
|
+
if (typeof source.signal_id !== "string" || !/^[A-Za-z0-9._:-]{1,256}$/.test(source.signal_id)) throw new Error("Actuals source close signal_id is invalid.");
|
|
32
|
+
if (typeof source.closed_through !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(source.closed_through)) throw new Error("Actuals source closed_through must use YYYY-MM-DD.");
|
|
33
|
+
const closedDate = new Date(`${source.closed_through}T00:00:00Z`);
|
|
34
|
+
if (Number.isNaN(closedDate.getTime()) || closedDate.toISOString().slice(0, 10) !== source.closed_through) {
|
|
35
|
+
throw new Error("Actuals source closed_through must be a real canonical calendar date.");
|
|
36
|
+
}
|
|
37
|
+
if (typeof source.emitted_at !== "string" || Number.isNaN(Date.parse(source.emitted_at))) throw new Error("Actuals source close emitted_at must be an ISO timestamp.");
|
|
38
|
+
const canonical = {
|
|
39
|
+
kind: "fpa.actuals.source-close" as const,
|
|
40
|
+
schema_version: 1 as const,
|
|
41
|
+
dataset: "ua_spend" as const,
|
|
42
|
+
signal_id: source.signal_id as string,
|
|
43
|
+
closed_through: source.closed_through as string,
|
|
44
|
+
emitted_at: new Date(source.emitted_at as string).toISOString(),
|
|
45
|
+
};
|
|
46
|
+
return { ...canonical, signal_sha256: createHash("sha256").update(stableJson(canonical)).digest("hex") };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function configuredSourceCloseSignal(): Promise<ActualsSourceCloseSignal | null> {
|
|
50
|
+
const path = process.env.FPA_ACTUALS_CLOSE_SIGNAL_PATH;
|
|
51
|
+
if (!path) return null;
|
|
52
|
+
const rootConfig = process.env.FPA_ACTUALS_CLOSE_SIGNAL_ROOT;
|
|
53
|
+
if (!rootConfig) throw new Error("FPA_ACTUALS_CLOSE_SIGNAL_ROOT is required when a source close signal path is configured.");
|
|
54
|
+
const resolvedPath = resolve(path);
|
|
55
|
+
const configuredRoot = resolve(rootConfig);
|
|
56
|
+
if ((await lstat(configuredRoot)).isSymbolicLink()) throw new Error("FPA_ACTUALS_CLOSE_SIGNAL_ROOT must not be a symbolic link.");
|
|
57
|
+
const trustedRoot = await realpath(configuredRoot);
|
|
58
|
+
const parent = await realpath(dirname(resolvedPath));
|
|
59
|
+
if (parent !== resolve(dirname(resolvedPath))) throw new Error("Actuals source close signal parent path must not contain symbolic links.");
|
|
60
|
+
const relation = relative(trustedRoot, parent);
|
|
61
|
+
if (relation.startsWith("..") || isAbsolute(relation)) throw new Error("Actuals source close signal escapes FPA_ACTUALS_CLOSE_SIGNAL_ROOT.");
|
|
62
|
+
const processUid = typeof process.getuid === "function" ? process.getuid() : null;
|
|
63
|
+
if (processUid === null) throw new Error("Configured source close signals require POSIX ownership checks.");
|
|
64
|
+
for (let directory = parent; ; directory = dirname(directory)) {
|
|
65
|
+
const metadata = await stat(directory);
|
|
66
|
+
if (!metadata.isDirectory() || (metadata.mode & 0o022) !== 0 || metadata.uid === processUid) {
|
|
67
|
+
throw new Error("Actuals source close signal directory chain must be owned outside the Agent identity and not be group- or world-writable.");
|
|
68
|
+
}
|
|
69
|
+
if (dirname(directory) === directory) break;
|
|
70
|
+
}
|
|
71
|
+
const handle = await open(resolvedPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
72
|
+
try {
|
|
73
|
+
const metadata = await handle.stat();
|
|
74
|
+
if (!metadata.isFile() || metadata.size > 16 * 1024) throw new Error("FPA_ACTUALS_CLOSE_SIGNAL_PATH must be a bounded regular file.");
|
|
75
|
+
if ((metadata.mode & 0o022) !== 0) throw new Error("Actuals source close signal must not be group- or world-writable.");
|
|
76
|
+
if (metadata.uid === processUid) throw new Error("Actuals source close signal must be owned outside the Agent identity.");
|
|
77
|
+
return validateSourceCloseSignal(JSON.parse(await handle.readFile("utf8")));
|
|
78
|
+
} finally {
|
|
79
|
+
await handle.close();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
9
82
|
|
|
10
83
|
function dateInTimezone(timestamp: string, timezone: string, label: string): string {
|
|
11
84
|
const instant = new Date(timestamp);
|
|
@@ -51,6 +124,74 @@ function receipt(dataset: string, sql: string, rowCount: number): QueryReceipt {
|
|
|
51
124
|
return { dataset, query_fingerprint: queryFingerprint(sql), row_count: rowCount };
|
|
52
125
|
}
|
|
53
126
|
|
|
127
|
+
function approximatelyEqual(left: number, right: number): boolean {
|
|
128
|
+
return Math.abs(left - right) <= Math.max(0.000001, Math.max(Math.abs(left), Math.abs(right)) * 0.000001);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function jsonValue(value: unknown): unknown {
|
|
132
|
+
if (typeof value !== "string") return value;
|
|
133
|
+
try {
|
|
134
|
+
return JSON.parse(value);
|
|
135
|
+
} catch {
|
|
136
|
+
return value;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function jsonRows(value: unknown, label: string): SqlRow[] {
|
|
141
|
+
const parsed = jsonValue(value);
|
|
142
|
+
if (!Array.isArray(parsed)) throw new Error(`Unified Actuals snapshot ${label} must be a JSON array.`);
|
|
143
|
+
if (parsed.some((row) => row === null || typeof row !== "object" || Array.isArray(row))) throw new Error(`Unified Actuals snapshot ${label} contains an invalid row.`);
|
|
144
|
+
return parsed as SqlRow[];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function jsonRow(value: unknown, label: string): SqlRow | undefined {
|
|
148
|
+
const parsed = jsonValue(value);
|
|
149
|
+
if (parsed === null || parsed === undefined) return undefined;
|
|
150
|
+
if (typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`Unified Actuals snapshot ${label} must be a JSON object or null.`);
|
|
151
|
+
return parsed as SqlRow;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function unifiedSnapshotSql(queries: Record<string, ReturnType<typeof buildQuery>>): string {
|
|
155
|
+
return [
|
|
156
|
+
"with",
|
|
157
|
+
`snapshot_current as (${queries.current.sql}),`,
|
|
158
|
+
`snapshot_comparison as (${queries.comparison.sql}),`,
|
|
159
|
+
`snapshot_daily as (${queries.daily.sql}),`,
|
|
160
|
+
`snapshot_approved as (${queries.approved.sql}),`,
|
|
161
|
+
`snapshot_discovered as (${queries.discovered.sql}),`,
|
|
162
|
+
`snapshot_unscoped as (${queries.unscoped.sql})`,
|
|
163
|
+
"select",
|
|
164
|
+
"txid_current_snapshot()::text as snapshot_id,",
|
|
165
|
+
"statement_timestamp()::text as available_at,",
|
|
166
|
+
"(select row_to_json(snapshot_current) from snapshot_current limit 1) as current,",
|
|
167
|
+
"(select row_to_json(snapshot_comparison) from snapshot_comparison limit 1) as comparison,",
|
|
168
|
+
"coalesce((select json_agg(row_to_json(snapshot_daily) order by period) from snapshot_daily), '[]'::json) as daily,",
|
|
169
|
+
"coalesce((select json_agg(row_to_json(snapshot_approved)) from snapshot_approved), '[]'::json) as approved,",
|
|
170
|
+
"coalesce((select json_agg(row_to_json(snapshot_discovered)) from snapshot_discovered), '[]'::json) as discovered,",
|
|
171
|
+
"(select row_to_json(snapshot_unscoped) from snapshot_unscoped limit 1) as unscoped",
|
|
172
|
+
].join(" ");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function dashboardActualsWatermark(actuals: DashboardActualsSnapshot): string {
|
|
176
|
+
const normalized = validateActualsSnapshot(actuals);
|
|
177
|
+
return createHash("sha256").update(stableJson({
|
|
178
|
+
period: normalized.period,
|
|
179
|
+
data_as_of: normalized.data_as_of,
|
|
180
|
+
coverage: normalized.coverage,
|
|
181
|
+
current: normalized.current,
|
|
182
|
+
comparison: normalized.comparison,
|
|
183
|
+
daily: normalized.daily,
|
|
184
|
+
slices: normalized.slices,
|
|
185
|
+
period_has_unscoped_actuals: normalized.period_has_unscoped_actuals,
|
|
186
|
+
period_complete: normalized.snapshot_evidence.period_complete,
|
|
187
|
+
reconciled: normalized.snapshot_evidence.reconciled,
|
|
188
|
+
})).digest("hex");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function dashboardActualsSnapshotFingerprint(actuals: DashboardActualsSnapshot): string {
|
|
192
|
+
return createHash("sha256").update(stableJson(validateActualsSnapshot(actuals))).digest("hex");
|
|
193
|
+
}
|
|
194
|
+
|
|
54
195
|
export function calendarQueryDates(period: Period): {
|
|
55
196
|
currentFrom: string;
|
|
56
197
|
currentTo: string;
|
|
@@ -97,6 +238,32 @@ export interface DashboardActualsScope {
|
|
|
97
238
|
slices: Array<{ app_id: string; store: string; channel_group: string }>;
|
|
98
239
|
}
|
|
99
240
|
|
|
241
|
+
/**
|
|
242
|
+
* Why every approved slice can be missing, and which of the two reasons it is.
|
|
243
|
+
*
|
|
244
|
+
* An early cycle and a forecast keyed on values ua_spend never uses produce
|
|
245
|
+
* byte-identical Actuals: nothing. The difference is only visible next to the
|
|
246
|
+
* unscoped probe — if the window holds paid rows that no approved key matched,
|
|
247
|
+
* the keys are wrong. Returns the operator-facing explanation, or null when the
|
|
248
|
+
* evidence does not support that conclusion.
|
|
249
|
+
*/
|
|
250
|
+
export function detectSliceKeyMismatch(
|
|
251
|
+
allocation: Array<{ app_id: string; store: string; channel_group: string }>,
|
|
252
|
+
actuals: Pick<DashboardActualsSnapshot, "slices" | "period_has_unscoped_actuals" | "period">,
|
|
253
|
+
): string | null {
|
|
254
|
+
if (allocation.length === 0) return null;
|
|
255
|
+
if (!actuals.period_has_unscoped_actuals) return null;
|
|
256
|
+
const actualKeys = new Set(actuals.slices.map(sliceKey));
|
|
257
|
+
const missing = allocation.filter((slice) => !actualKeys.has(sliceKey(slice)));
|
|
258
|
+
if (missing.length < allocation.length) return null;
|
|
259
|
+
return (
|
|
260
|
+
`All ${allocation.length} approved slices have no Actuals, yet ua_spend holds paid rows in `
|
|
261
|
+
+ `${actuals.period.start_inclusive.slice(0, 10)}..${actuals.period.end_exclusive.slice(0, 10)}. `
|
|
262
|
+
+ "The frozen slice keys do not match the dimension values ua_spend uses — check store against "
|
|
263
|
+
+ "ua_spend.platform and channel_group against ua_spend.media_source, then re-freeze the forecast."
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
100
267
|
/**
|
|
101
268
|
* Rows carry app_code only in "app" scope mode. In "portfolio" mode the App axis was
|
|
102
269
|
* collapsed onto a placeholder before the query ran, so the caller supplies it back.
|
|
@@ -148,8 +315,12 @@ export async function loadDashboardActuals(
|
|
|
148
315
|
period: Period,
|
|
149
316
|
scope: DashboardActualsScope,
|
|
150
317
|
signal?: AbortSignal,
|
|
318
|
+
options: { sourceCloseSignal?: ActualsSourceCloseSignal | null } = {},
|
|
151
319
|
): Promise<DashboardActualsSnapshot> {
|
|
152
320
|
if (scope.slices.length === 0) throw new Error("Dashboard Actuals scope requires at least one approved slice.");
|
|
321
|
+
const closeSignal = options.sourceCloseSignal === undefined
|
|
322
|
+
? await configuredSourceCloseSignal()
|
|
323
|
+
: validateSourceCloseSignal(options.sourceCloseSignal);
|
|
153
324
|
const dates = calendarQueryDates(period);
|
|
154
325
|
const uniqueSlices = [...new Map(scope.slices.map((slice) => [`${slice.app_id}\u0000${slice.store}\u0000${slice.channel_group}`, slice])).values()];
|
|
155
326
|
const common = {
|
|
@@ -169,41 +340,40 @@ export async function loadDashboardActuals(
|
|
|
169
340
|
? { filters: { platform: [...new Set(uniqueSlices.map((slice) => slice.store))] } }
|
|
170
341
|
: { exactUaPortfolioScope };
|
|
171
342
|
const sliceDimensions = portfolio ? ["platform", "media_source"] : ["app_code", "platform", "media_source"];
|
|
172
|
-
const
|
|
173
|
-
const
|
|
343
|
+
const periodDays = calendarDayDistance(dates.currentFrom, dates.currentTo) + 1;
|
|
344
|
+
const priorTo = addCalendarDays(dates.currentFrom, -1);
|
|
345
|
+
const priorFrom = addCalendarDays(priorTo, -(periodDays - 1));
|
|
346
|
+
const specs: Record<string, QuerySpec> = {
|
|
347
|
+
current: { ...common, ...sliceScope, dateFrom: dates.currentFrom, dateTo: dates.currentTo, limit: 1 },
|
|
348
|
+
comparison: { ...common, ...sliceScope, dateFrom: priorFrom, dateTo: priorTo, limit: 1 },
|
|
349
|
+
daily: { ...common, ...sliceScope, dateFrom: dates.currentFrom, dateTo: dates.currentTo, timeGrain: "day", limit: 1000 },
|
|
350
|
+
approved: { ...common, dateFrom: dates.currentFrom, dateTo: dates.currentTo, ...sliceScope, dimensions: sliceDimensions, limit: 500 },
|
|
351
|
+
discovered: { ...common, dateFrom: dates.currentFrom, dateTo: dates.currentTo, ...discoveryScope, dimensions: sliceDimensions, limit: 1000 },
|
|
352
|
+
unscoped: { ...common, dateFrom: dates.currentFrom, dateTo: dates.currentTo, limit: 1 },
|
|
353
|
+
};
|
|
354
|
+
const built = Object.fromEntries(Object.entries(specs).map(([name, spec]) => [name, buildQuery(spec)])) as Record<string, ReturnType<typeof buildQuery>>;
|
|
355
|
+
const snapshotSql = unifiedSnapshotSql(built);
|
|
356
|
+
const snapshotRows = await runQuery(snapshotSql, { signal });
|
|
357
|
+
if (snapshotRows.length !== 1) throw new Error("Unified Actuals snapshot query must return exactly one envelope row.");
|
|
358
|
+
const envelope = snapshotRows[0];
|
|
359
|
+
const shape = (name: string, rows: SqlRow[]) => shapeQueryRows(rows, built[name].measures, built[name].derived);
|
|
360
|
+
const currentRow = shape("current", jsonRow(envelope.current, "current") ? [jsonRow(envelope.current, "current") as SqlRow] : [])[0];
|
|
361
|
+
const comparisonRow = shape("comparison", jsonRow(envelope.comparison, "comparison") ? [jsonRow(envelope.comparison, "comparison") as SqlRow] : [])[0];
|
|
362
|
+
const dailyRows = shape("daily", jsonRows(envelope.daily, "daily"));
|
|
363
|
+
const approvedRows = shape("approved", jsonRows(envelope.approved, "approved"));
|
|
364
|
+
const discoveredRows = shape("discovered", jsonRows(envelope.discovered, "discovered"));
|
|
365
|
+
const unscopedRow = shape("unscoped", jsonRow(envelope.unscoped, "unscoped") ? [jsonRow(envelope.unscoped, "unscoped") as SqlRow] : [])[0];
|
|
174
366
|
const coverageMin = currentRow?.date_min == null ? null : String(currentRow.date_min);
|
|
175
367
|
const coverageMax = currentRow?.date_max == null ? null : String(currentRow.date_max);
|
|
176
|
-
const
|
|
177
|
-
const [comparison, daily, approvedSlices, discoveredSlices] = await Promise.all([
|
|
178
|
-
comparisonDates
|
|
179
|
-
? runStructuredQuery({ ...common, ...sliceScope, dateFrom: comparisonDates.comparisonFrom, dateTo: comparisonDates.comparisonTo, limit: 1 }, signal)
|
|
180
|
-
: Promise.resolve(null),
|
|
181
|
-
runStructuredQuery({ ...common, ...sliceScope, dateFrom: dates.currentFrom, dateTo: dates.currentTo, timeGrain: "day", limit: 1000 }, signal),
|
|
182
|
-
runStructuredQuery({
|
|
183
|
-
...common,
|
|
184
|
-
dateFrom: dates.currentFrom,
|
|
185
|
-
dateTo: dates.currentTo,
|
|
186
|
-
...sliceScope,
|
|
187
|
-
dimensions: sliceDimensions,
|
|
188
|
-
limit: 500,
|
|
189
|
-
}, signal),
|
|
190
|
-
runStructuredQuery({
|
|
191
|
-
...common,
|
|
192
|
-
dateFrom: dates.currentFrom,
|
|
193
|
-
dateTo: dates.currentTo,
|
|
194
|
-
...discoveryScope,
|
|
195
|
-
dimensions: sliceDimensions,
|
|
196
|
-
limit: 1000,
|
|
197
|
-
}, signal),
|
|
198
|
-
]);
|
|
199
|
-
|
|
200
|
-
const comparisonRow = comparison?.rows[0];
|
|
368
|
+
const periodHasUnscopedActuals = unscopedRow?.date_max != null;
|
|
201
369
|
const queryReceipts = [
|
|
202
|
-
receipt("ua_spend.
|
|
203
|
-
|
|
204
|
-
receipt("ua_spend.
|
|
205
|
-
receipt("ua_spend.
|
|
206
|
-
receipt("ua_spend.slices.
|
|
370
|
+
receipt("ua_spend.snapshot", snapshotSql, snapshotRows.length),
|
|
371
|
+
receipt("ua_spend.current", snapshotSql, currentRow ? 1 : 0),
|
|
372
|
+
receipt("ua_spend.comparison", snapshotSql, comparisonRow ? 1 : 0),
|
|
373
|
+
receipt("ua_spend.daily", snapshotSql, dailyRows.length),
|
|
374
|
+
receipt("ua_spend.slices.approved", snapshotSql, approvedRows.length),
|
|
375
|
+
receipt("ua_spend.slices.discovery", snapshotSql, discoveredRows.length),
|
|
376
|
+
receipt("ua_spend.period.unscoped", snapshotSql, periodHasUnscopedActuals ? 1 : 0),
|
|
207
377
|
];
|
|
208
378
|
const approvedKeys = new Set(
|
|
209
379
|
portfolio
|
|
@@ -213,25 +383,58 @@ export async function loadDashboardActuals(
|
|
|
213
383
|
const rowKey = (row: SqlRow): string =>
|
|
214
384
|
portfolio ? `${row.platform}\u0000${row.media_source}` : `${row.app_code}\u0000${row.platform}\u0000${row.media_source}`;
|
|
215
385
|
const sliceRows = [
|
|
216
|
-
...
|
|
217
|
-
...
|
|
386
|
+
...approvedRows,
|
|
387
|
+
...discoveredRows.filter((row) => !approvedKeys.has(rowKey(row))),
|
|
218
388
|
];
|
|
389
|
+
const dailySeries = completeDailySeries(dailyRows, dates.currentFrom, coverageMax);
|
|
390
|
+
const currentSpend = value(currentRow, "spend");
|
|
391
|
+
const currentRevenue = value(currentRow, "revenue");
|
|
392
|
+
const dailyComplete = coverageMin === dates.currentFrom && coverageMax === dates.currentTo
|
|
393
|
+
&& dailySeries.length === periodDays
|
|
394
|
+
&& dailySeries.every((point) => point.spend !== null && point.revenue !== null);
|
|
395
|
+
const approvedSpend = approvedRows.reduce<number | null>((sum, row) => sum === null || value(row, "spend") === null ? null : sum + (value(row, "spend") as number), 0);
|
|
396
|
+
const approvedRevenue = approvedRows.reduce<number | null>((sum, row) => sum === null || value(row, "revenue") === null ? null : sum + (value(row, "revenue") as number), 0);
|
|
397
|
+
const dailySpend = dailySeries.reduce<number | null>((sum, point) => sum === null || point.spend === null ? null : sum + point.spend, 0);
|
|
398
|
+
const dailyRevenue = dailySeries.reduce<number | null>((sum, point) => sum === null || point.revenue === null ? null : sum + point.revenue, 0);
|
|
399
|
+
const reconciled = dailyComplete && currentSpend !== null && currentRevenue !== null
|
|
400
|
+
&& approvedSpend !== null && approvedRevenue !== null && dailySpend !== null && dailyRevenue !== null
|
|
401
|
+
&& approximatelyEqual(currentSpend, approvedSpend) && approximatelyEqual(currentRevenue, approvedRevenue)
|
|
402
|
+
&& approximatelyEqual(currentSpend, dailySpend) && approximatelyEqual(currentRevenue, dailyRevenue);
|
|
403
|
+
const snapshotId = envelope.snapshot_id == null ? null : String(envelope.snapshot_id);
|
|
404
|
+
const availableAt = envelope.available_at == null ? null : new Date(String(envelope.available_at)).toISOString();
|
|
405
|
+
// Date coverage and elapsed time are not close signals. Only an explicit ETL
|
|
406
|
+
// control-plane marker may close a cycle; absent evidence fails closed.
|
|
407
|
+
const periodComplete = dailyComplete && closeSignal !== null && availableAt !== null
|
|
408
|
+
&& closeSignal.closed_through >= dates.currentTo
|
|
409
|
+
&& Date.parse(availableAt) >= Date.parse(closeSignal.emitted_at);
|
|
219
410
|
|
|
220
411
|
return {
|
|
221
412
|
period,
|
|
222
413
|
data_as_of: coverageMax ?? ACTUALS_DATA_AS_OF_UNAVAILABLE,
|
|
414
|
+
snapshot_evidence: {
|
|
415
|
+
snapshot_id: snapshotId,
|
|
416
|
+
available_at: availableAt,
|
|
417
|
+
consistency: "single_statement",
|
|
418
|
+
source_close_signal_id: closeSignal?.signal_id ?? null,
|
|
419
|
+
source_closed_through: closeSignal?.closed_through ?? null,
|
|
420
|
+
source_close_emitted_at: closeSignal?.emitted_at ?? null,
|
|
421
|
+
source_close_signal_sha256: closeSignal?.signal_sha256 ?? null,
|
|
422
|
+
period_complete: periodComplete,
|
|
423
|
+
reconciled,
|
|
424
|
+
},
|
|
223
425
|
reporting_currency: "USD",
|
|
224
426
|
scope_mode: mode,
|
|
427
|
+
period_has_unscoped_actuals: periodHasUnscopedActuals,
|
|
225
428
|
coverage: {
|
|
226
429
|
date_min: coverageMin,
|
|
227
430
|
date_max: coverageMax,
|
|
228
431
|
source_rows: value(currentRow, "source_rows") ?? 0,
|
|
229
432
|
},
|
|
230
|
-
current: { spend:
|
|
231
|
-
comparison: comparisonRow
|
|
433
|
+
current: { spend: currentSpend, revenue: currentRevenue },
|
|
434
|
+
comparison: periodComplete && comparisonRow
|
|
232
435
|
? { spend: value(comparisonRow, "spend"), revenue: value(comparisonRow, "revenue") }
|
|
233
436
|
: null,
|
|
234
|
-
daily:
|
|
437
|
+
daily: dailySeries,
|
|
235
438
|
slices: mapDashboardSlices(sliceRows, portfolioAppId ?? undefined),
|
|
236
439
|
query_receipts: queryReceipts,
|
|
237
440
|
};
|