@viccydev/pi-fpa 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -4
- package/bin/fpa-dashboard-worker.mjs +98 -0
- package/extensions/fpa-artifacts/compose.ts +4 -1
- package/extensions/fpa-artifacts/contracts.ts +9 -1
- package/extensions/fpa-artifacts/index.ts +85 -4
- package/extensions/fpa-artifacts/store.ts +355 -39
- package/extensions/fpa-dashboard/actuals.ts +215 -51
- 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 +88 -79
- 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 +118 -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 +7 -2
- package/skills/fpa-forecast-approved-strategy/references/artifact-contract.md +2 -0
- package/skills/fpa-refresh-dashboard/SKILL.md +9 -1
|
@@ -1,12 +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";
|
|
4
7
|
import { sliceKey } from "../fpa-artifacts/contracts.ts";
|
|
8
|
+
import { stableJson } from "../fpa-artifacts/store.ts";
|
|
5
9
|
import { toNumber, type NumericLike } from "../fpa-data/calc.ts";
|
|
6
|
-
import { runStructuredQuery } from "../fpa-data/runtime.ts";
|
|
7
|
-
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";
|
|
8
13
|
import type { DashboardActualsSnapshot, DashboardDailyPoint, DashboardScopeMode, QueryReceipt } from "./source.ts";
|
|
9
|
-
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
|
+
}
|
|
10
82
|
|
|
11
83
|
function dateInTimezone(timestamp: string, timezone: string, label: string): string {
|
|
12
84
|
const instant = new Date(timestamp);
|
|
@@ -52,6 +124,74 @@ function receipt(dataset: string, sql: string, rowCount: number): QueryReceipt {
|
|
|
52
124
|
return { dataset, query_fingerprint: queryFingerprint(sql), row_count: rowCount };
|
|
53
125
|
}
|
|
54
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
|
+
|
|
55
195
|
export function calendarQueryDates(period: Period): {
|
|
56
196
|
currentFrom: string;
|
|
57
197
|
currentTo: string;
|
|
@@ -175,8 +315,12 @@ export async function loadDashboardActuals(
|
|
|
175
315
|
period: Period,
|
|
176
316
|
scope: DashboardActualsScope,
|
|
177
317
|
signal?: AbortSignal,
|
|
318
|
+
options: { sourceCloseSignal?: ActualsSourceCloseSignal | null } = {},
|
|
178
319
|
): Promise<DashboardActualsSnapshot> {
|
|
179
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);
|
|
180
324
|
const dates = calendarQueryDates(period);
|
|
181
325
|
const uniqueSlices = [...new Map(scope.slices.map((slice) => [`${slice.app_id}\u0000${slice.store}\u0000${slice.channel_group}`, slice])).values()];
|
|
182
326
|
const common = {
|
|
@@ -196,52 +340,40 @@ export async function loadDashboardActuals(
|
|
|
196
340
|
? { filters: { platform: [...new Set(uniqueSlices.map((slice) => slice.store))] } }
|
|
197
341
|
: { exactUaPortfolioScope };
|
|
198
342
|
const sliceDimensions = portfolio ? ["platform", "media_source"] : ["app_code", "platform", "media_source"];
|
|
199
|
-
const
|
|
200
|
-
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];
|
|
201
366
|
const coverageMin = currentRow?.date_min == null ? null : String(currentRow.date_min);
|
|
202
367
|
const coverageMax = currentRow?.date_max == null ? null : String(currentRow.date_max);
|
|
203
|
-
const
|
|
204
|
-
const [comparison, daily, approvedSlices, discoveredSlices] = await Promise.all([
|
|
205
|
-
comparisonDates
|
|
206
|
-
? runStructuredQuery({ ...common, ...sliceScope, dateFrom: comparisonDates.comparisonFrom, dateTo: comparisonDates.comparisonTo, limit: 1 }, signal)
|
|
207
|
-
: Promise.resolve(null),
|
|
208
|
-
runStructuredQuery({ ...common, ...sliceScope, dateFrom: dates.currentFrom, dateTo: dates.currentTo, timeGrain: "day", limit: 1000 }, signal),
|
|
209
|
-
runStructuredQuery({
|
|
210
|
-
...common,
|
|
211
|
-
dateFrom: dates.currentFrom,
|
|
212
|
-
dateTo: dates.currentTo,
|
|
213
|
-
...sliceScope,
|
|
214
|
-
dimensions: sliceDimensions,
|
|
215
|
-
limit: 500,
|
|
216
|
-
}, signal),
|
|
217
|
-
runStructuredQuery({
|
|
218
|
-
...common,
|
|
219
|
-
dateFrom: dates.currentFrom,
|
|
220
|
-
dateTo: dates.currentTo,
|
|
221
|
-
...discoveryScope,
|
|
222
|
-
dimensions: sliceDimensions,
|
|
223
|
-
limit: 1000,
|
|
224
|
-
}, signal),
|
|
225
|
-
]);
|
|
226
|
-
|
|
227
|
-
const comparisonRow = comparison?.rows[0];
|
|
228
|
-
// Only worth asking when the scoped queries came back empty, which is the one
|
|
229
|
-
// case where "no Actuals" is ambiguous between an early cycle and slice keys
|
|
230
|
-
// that match nothing.
|
|
231
|
-
const scopedFoundNothing = coverageMax === null || approvedSlices.rows.length === 0;
|
|
232
|
-
const unscopedProbe = scopedFoundNothing
|
|
233
|
-
? await runStructuredQuery({ ...common, dateFrom: dates.currentFrom, dateTo: dates.currentTo, limit: 1 }, signal)
|
|
234
|
-
: null;
|
|
235
|
-
const unscopedRow = unscopedProbe?.rows[0];
|
|
236
|
-
const periodHasUnscopedActuals = unscopedProbe !== null && unscopedRow?.date_max != null;
|
|
237
|
-
|
|
368
|
+
const periodHasUnscopedActuals = unscopedRow?.date_max != null;
|
|
238
369
|
const queryReceipts = [
|
|
239
|
-
receipt("ua_spend.
|
|
240
|
-
|
|
241
|
-
receipt("ua_spend.
|
|
242
|
-
receipt("ua_spend.
|
|
243
|
-
receipt("ua_spend.slices.
|
|
244
|
-
|
|
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),
|
|
245
377
|
];
|
|
246
378
|
const approvedKeys = new Set(
|
|
247
379
|
portfolio
|
|
@@ -251,13 +383,45 @@ export async function loadDashboardActuals(
|
|
|
251
383
|
const rowKey = (row: SqlRow): string =>
|
|
252
384
|
portfolio ? `${row.platform}\u0000${row.media_source}` : `${row.app_code}\u0000${row.platform}\u0000${row.media_source}`;
|
|
253
385
|
const sliceRows = [
|
|
254
|
-
...
|
|
255
|
-
...
|
|
386
|
+
...approvedRows,
|
|
387
|
+
...discoveredRows.filter((row) => !approvedKeys.has(rowKey(row))),
|
|
256
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);
|
|
257
410
|
|
|
258
411
|
return {
|
|
259
412
|
period,
|
|
260
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
|
+
},
|
|
261
425
|
reporting_currency: "USD",
|
|
262
426
|
scope_mode: mode,
|
|
263
427
|
period_has_unscoped_actuals: periodHasUnscopedActuals,
|
|
@@ -266,11 +430,11 @@ export async function loadDashboardActuals(
|
|
|
266
430
|
date_max: coverageMax,
|
|
267
431
|
source_rows: value(currentRow, "source_rows") ?? 0,
|
|
268
432
|
},
|
|
269
|
-
current: { spend:
|
|
270
|
-
comparison: comparisonRow
|
|
433
|
+
current: { spend: currentSpend, revenue: currentRevenue },
|
|
434
|
+
comparison: periodComplete && comparisonRow
|
|
271
435
|
? { spend: value(comparisonRow, "spend"), revenue: value(comparisonRow, "revenue") }
|
|
272
436
|
: null,
|
|
273
|
-
daily:
|
|
437
|
+
daily: dailySeries,
|
|
274
438
|
slices: mapDashboardSlices(sliceRows, portfolioAppId ?? undefined),
|
|
275
439
|
query_receipts: queryReceipts,
|
|
276
440
|
};
|