Fix financial taxonomy snapshot normalization
This commit is contained in:
@@ -45,6 +45,7 @@ import {
|
|||||||
type NumberScaleUnit
|
type NumberScaleUnit
|
||||||
} from '@/lib/format';
|
} from '@/lib/format';
|
||||||
import { buildGraphingHref } from '@/lib/graphing/catalog';
|
import { buildGraphingHref } from '@/lib/graphing/catalog';
|
||||||
|
import { mergeFinancialPages } from '@/lib/financials/page-merge';
|
||||||
import {
|
import {
|
||||||
buildStatementTree,
|
buildStatementTree,
|
||||||
resolveStatementSelection,
|
resolveStatementSelection,
|
||||||
@@ -63,7 +64,6 @@ import type {
|
|||||||
RatioRow,
|
RatioRow,
|
||||||
StandardizedFinancialRow,
|
StandardizedFinancialRow,
|
||||||
StructuredKpiRow,
|
StructuredKpiRow,
|
||||||
SurfaceDetailMap,
|
|
||||||
SurfaceFinancialRow,
|
SurfaceFinancialRow,
|
||||||
TaxonomyStatementRow,
|
TaxonomyStatementRow,
|
||||||
TrendSeries
|
TrendSeries
|
||||||
@@ -345,90 +345,6 @@ function groupRows(rows: FlatDisplayRow[], categories: CompanyFinancialStatement
|
|||||||
.filter((group) => group.rows.length > 0);
|
.filter((group) => group.rows.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeDetailMaps(base: SurfaceDetailMap | null, next: SurfaceDetailMap | null) {
|
|
||||||
if (!base) {
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!next) {
|
|
||||||
return base;
|
|
||||||
}
|
|
||||||
|
|
||||||
const merged: SurfaceDetailMap = structuredClone(base);
|
|
||||||
|
|
||||||
for (const [surfaceKey, detailRows] of Object.entries(next)) {
|
|
||||||
const existingRows = merged[surfaceKey] ?? [];
|
|
||||||
const rowMap = new Map(existingRows.map((row) => [row.key, row]));
|
|
||||||
|
|
||||||
for (const detailRow of detailRows) {
|
|
||||||
const existing = rowMap.get(detailRow.key);
|
|
||||||
if (!existing) {
|
|
||||||
rowMap.set(detailRow.key, structuredClone(detailRow));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
existing.values = {
|
|
||||||
...existing.values,
|
|
||||||
...detailRow.values
|
|
||||||
};
|
|
||||||
existing.sourceFactIds = [...new Set([...existing.sourceFactIds, ...detailRow.sourceFactIds])];
|
|
||||||
existing.dimensionsSummary = [...new Set([...existing.dimensionsSummary, ...detailRow.dimensionsSummary])];
|
|
||||||
}
|
|
||||||
|
|
||||||
merged[surfaceKey] = [...rowMap.values()];
|
|
||||||
}
|
|
||||||
|
|
||||||
return merged;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeFinancialPages(
|
|
||||||
base: CompanyFinancialStatementsResponse | null,
|
|
||||||
next: CompanyFinancialStatementsResponse
|
|
||||||
) {
|
|
||||||
if (!base) {
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
const periods = [...base.periods, ...next.periods]
|
|
||||||
.filter((period, index, list) => list.findIndex((item) => item.id === period.id) === index)
|
|
||||||
.sort((left, right) => Date.parse(left.periodEnd ?? left.filingDate) - Date.parse(right.periodEnd ?? right.filingDate));
|
|
||||||
|
|
||||||
const mergeRows = <T extends { key: string; values: Record<string, number | null> }>(rows: T[]) => {
|
|
||||||
const map = new Map<string, T>();
|
|
||||||
for (const row of rows) {
|
|
||||||
const existing = map.get(row.key);
|
|
||||||
if (!existing) {
|
|
||||||
map.set(row.key, structuredClone(row));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
existing.values = {
|
|
||||||
...existing.values,
|
|
||||||
...row.values
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...map.values()];
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
...next,
|
|
||||||
periods,
|
|
||||||
statementRows: next.statementRows && base.statementRows
|
|
||||||
? {
|
|
||||||
faithful: mergeRows([...base.statementRows.faithful, ...next.statementRows.faithful]),
|
|
||||||
standardized: mergeRows([...base.statementRows.standardized, ...next.statementRows.standardized])
|
|
||||||
}
|
|
||||||
: next.statementRows,
|
|
||||||
statementDetails: mergeDetailMaps(base.statementDetails, next.statementDetails),
|
|
||||||
ratioRows: next.ratioRows && base.ratioRows ? mergeRows([...base.ratioRows, ...next.ratioRows]) : next.ratioRows,
|
|
||||||
kpiRows: next.kpiRows && base.kpiRows ? mergeRows([...base.kpiRows, ...next.kpiRows]) : next.kpiRows,
|
|
||||||
trendSeries: next.trendSeries,
|
|
||||||
categories: next.categories,
|
|
||||||
dimensionBreakdown: next.dimensionBreakdown ?? base.dimensionBreakdown
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChartFrame({ children }: { children: React.ReactNode }) {
|
function ChartFrame({ children }: { children: React.ReactNode }) {
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
@@ -1185,11 +1101,11 @@ function FinancialsPageContent() {
|
|||||||
{isDerivedRow(selectedRow) ? (
|
{isDerivedRow(selectedRow) ? (
|
||||||
<div className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
|
<div className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
|
||||||
<p className="text-[color:var(--terminal-muted)]">Source Row Keys</p>
|
<p className="text-[color:var(--terminal-muted)]">Source Row Keys</p>
|
||||||
<p className="font-semibold text-[color:var(--terminal-bright)]">{selectedRow.sourceRowKeys.join(', ') || 'n/a'}</p>
|
<p className="font-semibold text-[color:var(--terminal-bright)]">{(selectedRow.sourceRowKeys ?? []).join(', ') || 'n/a'}</p>
|
||||||
<p className="mt-2 text-[color:var(--terminal-muted)]">Source Concepts</p>
|
<p className="mt-2 text-[color:var(--terminal-muted)]">Source Concepts</p>
|
||||||
<p className="font-semibold text-[color:var(--terminal-bright)]">{selectedRow.sourceConcepts.join(', ') || 'n/a'}</p>
|
<p className="font-semibold text-[color:var(--terminal-bright)]">{(selectedRow.sourceConcepts ?? []).join(', ') || 'n/a'}</p>
|
||||||
<p className="mt-2 text-[color:var(--terminal-muted)]">Source Fact IDs</p>
|
<p className="mt-2 text-[color:var(--terminal-muted)]">Source Fact IDs</p>
|
||||||
<p className="font-semibold text-[color:var(--terminal-bright)]">{selectedRow.sourceFactIds.join(', ') || 'n/a'}</p>
|
<p className="font-semibold text-[color:var(--terminal-bright)]">{(selectedRow.sourceFactIds ?? []).join(', ') || 'n/a'}</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -1271,7 +1187,7 @@ function FinancialsPageContent() {
|
|||||||
surfaceKind
|
surfaceKind
|
||||||
})}</td>
|
})}</td>
|
||||||
<td>{check.status}</td>
|
<td>{check.status}</td>
|
||||||
<td>{check.evidencePages.join(', ') || 'n/a'}</td>
|
<td>{(check.evidencePages ?? []).join(', ') || 'n/a'}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ function InspectorCard(props: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderList(values: string[]) {
|
function renderList(values: string[] | null | undefined) {
|
||||||
return values.length > 0 ? values.join(', ') : 'n/a';
|
return (values ?? []).length > 0 ? (values ?? []).join(', ') : 'n/a';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatementRowInspector(props: StatementRowInspectorProps) {
|
export function StatementRowInspector(props: StatementRowInspectorProps) {
|
||||||
@@ -64,7 +64,7 @@ export function StatementRowInspector(props: StatementRowInspectorProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
<InspectorCard label="Source Fact IDs" value={selection.row.sourceFactIds.length > 0 ? selection.row.sourceFactIds.join(', ') : 'n/a'} />
|
<InspectorCard label="Source Fact IDs" value={(selection.row.sourceFactIds ?? []).length > 0 ? (selection.row.sourceFactIds ?? []).join(', ') : 'n/a'} />
|
||||||
<InspectorCard label="Warning Codes" value={renderList(selection.row.warningCodes ?? [])} />
|
<InspectorCard label="Warning Codes" value={renderList(selection.row.warningCodes ?? [])} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -136,7 +136,7 @@ export function StatementRowInspector(props: StatementRowInspectorProps) {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
<InspectorCard label="Local Name" value={selection.row.localName} />
|
<InspectorCard label="Local Name" value={selection.row.localName} />
|
||||||
<InspectorCard label="Source Fact IDs" value={selection.row.sourceFactIds.length > 0 ? selection.row.sourceFactIds.join(', ') : 'n/a'} />
|
<InspectorCard label="Source Fact IDs" value={(selection.row.sourceFactIds ?? []).length > 0 ? (selection.row.sourceFactIds ?? []).join(', ') : 'n/a'} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
|
<div className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
|
||||||
|
|||||||
185
lib/financials/page-merge.test.ts
Normal file
185
lib/financials/page-merge.test.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
import { __financialPageMergeInternals } from './page-merge';
|
||||||
|
import type { CompanyFinancialStatementsResponse } from '@/lib/types';
|
||||||
|
|
||||||
|
function createResponse(partial: Partial<CompanyFinancialStatementsResponse>): CompanyFinancialStatementsResponse {
|
||||||
|
return {
|
||||||
|
company: {
|
||||||
|
ticker: 'MSFT',
|
||||||
|
companyName: 'Microsoft Corporation',
|
||||||
|
cik: null
|
||||||
|
},
|
||||||
|
surfaceKind: 'income_statement',
|
||||||
|
cadence: 'annual',
|
||||||
|
displayModes: ['standardized', 'faithful'],
|
||||||
|
defaultDisplayMode: 'standardized',
|
||||||
|
periods: [],
|
||||||
|
statementRows: {
|
||||||
|
faithful: [],
|
||||||
|
standardized: []
|
||||||
|
},
|
||||||
|
statementDetails: null,
|
||||||
|
ratioRows: null,
|
||||||
|
kpiRows: null,
|
||||||
|
trendSeries: [],
|
||||||
|
categories: [],
|
||||||
|
availability: {
|
||||||
|
adjusted: false,
|
||||||
|
customMetrics: false
|
||||||
|
},
|
||||||
|
nextCursor: null,
|
||||||
|
facts: null,
|
||||||
|
coverage: {
|
||||||
|
filings: 0,
|
||||||
|
rows: 0,
|
||||||
|
dimensions: 0,
|
||||||
|
facts: 0
|
||||||
|
},
|
||||||
|
dataSourceStatus: {
|
||||||
|
enabled: true,
|
||||||
|
hydratedFilings: 0,
|
||||||
|
partialFilings: 0,
|
||||||
|
failedFilings: 0,
|
||||||
|
pendingFilings: 0,
|
||||||
|
queuedSync: false
|
||||||
|
},
|
||||||
|
metrics: {
|
||||||
|
taxonomy: null,
|
||||||
|
validation: null
|
||||||
|
},
|
||||||
|
normalization: {
|
||||||
|
parserEngine: 'fiscal-xbrl',
|
||||||
|
regime: 'us-gaap',
|
||||||
|
fiscalPack: 'core',
|
||||||
|
parserVersion: '0.1.0',
|
||||||
|
surfaceRowCount: 0,
|
||||||
|
detailRowCount: 0,
|
||||||
|
kpiRowCount: 0,
|
||||||
|
unmappedRowCount: 0,
|
||||||
|
materialUnmappedRowCount: 0,
|
||||||
|
warnings: []
|
||||||
|
},
|
||||||
|
dimensionBreakdown: null,
|
||||||
|
...partial
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('financial page merge helpers', () => {
|
||||||
|
it('merges detail maps safely when legacy detail rows are missing arrays', () => {
|
||||||
|
const merged = __financialPageMergeInternals.mergeDetailMaps(
|
||||||
|
{
|
||||||
|
revenue: [{
|
||||||
|
key: 'detail',
|
||||||
|
parentSurfaceKey: 'revenue',
|
||||||
|
label: 'Detail',
|
||||||
|
conceptKey: 'detail',
|
||||||
|
qname: 'us-gaap:Detail',
|
||||||
|
namespaceUri: 'http://fasb.org/us-gaap/2024',
|
||||||
|
localName: 'Detail',
|
||||||
|
unit: 'iso4217:USD',
|
||||||
|
values: { p1: 1 },
|
||||||
|
sourceFactIds: undefined,
|
||||||
|
isExtension: false,
|
||||||
|
dimensionsSummary: undefined,
|
||||||
|
residualFlag: false
|
||||||
|
} as never]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
revenue: [{
|
||||||
|
key: 'detail',
|
||||||
|
parentSurfaceKey: 'revenue',
|
||||||
|
label: 'Detail',
|
||||||
|
conceptKey: 'detail',
|
||||||
|
qname: 'us-gaap:Detail',
|
||||||
|
namespaceUri: 'http://fasb.org/us-gaap/2024',
|
||||||
|
localName: 'Detail',
|
||||||
|
unit: 'iso4217:USD',
|
||||||
|
values: { p2: 2 },
|
||||||
|
sourceFactIds: [2],
|
||||||
|
isExtension: false,
|
||||||
|
dimensionsSummary: ['region:americas'],
|
||||||
|
residualFlag: false
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(merged?.revenue?.[0]).toMatchObject({
|
||||||
|
values: { p1: 1, p2: 2 },
|
||||||
|
sourceFactIds: [2],
|
||||||
|
dimensionsSummary: ['region:americas']
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges paged financial responses safely when row arrays are partially missing', () => {
|
||||||
|
const base = createResponse({
|
||||||
|
periods: [{
|
||||||
|
id: 'p1',
|
||||||
|
filingId: 1,
|
||||||
|
accessionNumber: '0001',
|
||||||
|
filingDate: '2025-01-01',
|
||||||
|
periodStart: '2024-01-01',
|
||||||
|
periodEnd: '2024-12-31',
|
||||||
|
filingType: '10-K',
|
||||||
|
periodLabel: 'FY 2024'
|
||||||
|
}],
|
||||||
|
statementRows: {
|
||||||
|
faithful: undefined as never,
|
||||||
|
standardized: [{
|
||||||
|
key: 'revenue',
|
||||||
|
label: 'Revenue',
|
||||||
|
category: 'revenue',
|
||||||
|
order: 10,
|
||||||
|
unit: 'currency',
|
||||||
|
values: { p1: 1 },
|
||||||
|
sourceConcepts: [],
|
||||||
|
sourceRowKeys: [],
|
||||||
|
sourceFactIds: [],
|
||||||
|
formulaKey: null,
|
||||||
|
hasDimensions: false,
|
||||||
|
resolvedSourceRowKeys: { p1: 'revenue' }
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const next = createResponse({
|
||||||
|
periods: [{
|
||||||
|
id: 'p2',
|
||||||
|
filingId: 2,
|
||||||
|
accessionNumber: '0002',
|
||||||
|
filingDate: '2026-01-01',
|
||||||
|
periodStart: '2025-01-01',
|
||||||
|
periodEnd: '2025-12-31',
|
||||||
|
filingType: '10-K',
|
||||||
|
periodLabel: 'FY 2025'
|
||||||
|
}],
|
||||||
|
statementRows: {
|
||||||
|
faithful: [{
|
||||||
|
key: 'rev',
|
||||||
|
label: 'Revenue',
|
||||||
|
conceptKey: 'rev',
|
||||||
|
qname: 'us-gaap:Revenue',
|
||||||
|
namespaceUri: 'http://fasb.org/us-gaap/2024',
|
||||||
|
localName: 'Revenue',
|
||||||
|
isExtension: false,
|
||||||
|
statement: 'income',
|
||||||
|
roleUri: 'income',
|
||||||
|
order: 10,
|
||||||
|
depth: 0,
|
||||||
|
parentKey: null,
|
||||||
|
values: { p2: 2 },
|
||||||
|
units: { p2: 'iso4217:USD' },
|
||||||
|
hasDimensions: false,
|
||||||
|
sourceFactIds: []
|
||||||
|
}],
|
||||||
|
standardized: undefined as never
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const merged = __financialPageMergeInternals.mergeFinancialPages(base, next);
|
||||||
|
|
||||||
|
expect(merged.periods.map((period) => period.id)).toEqual(['p1', 'p2']);
|
||||||
|
expect(merged.statementRows).toMatchObject({
|
||||||
|
faithful: [{ key: 'rev' }],
|
||||||
|
standardized: [{ key: 'revenue', values: { p1: 1 } }]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
98
lib/financials/page-merge.ts
Normal file
98
lib/financials/page-merge.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import type {
|
||||||
|
CompanyFinancialStatementsResponse
|
||||||
|
} from '@/lib/types';
|
||||||
|
|
||||||
|
export function mergeDetailMaps(
|
||||||
|
base: CompanyFinancialStatementsResponse['statementDetails'],
|
||||||
|
next: CompanyFinancialStatementsResponse['statementDetails']
|
||||||
|
) {
|
||||||
|
if (!base) {
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!next) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged: NonNullable<CompanyFinancialStatementsResponse['statementDetails']> = structuredClone(base);
|
||||||
|
for (const [surfaceKey, detailRows] of Object.entries(next)) {
|
||||||
|
const existingRows = merged[surfaceKey] ?? [];
|
||||||
|
const rowMap = new Map(existingRows.map((row) => [row.key, row]));
|
||||||
|
|
||||||
|
for (const detailRow of detailRows) {
|
||||||
|
const existing = rowMap.get(detailRow.key);
|
||||||
|
if (!existing) {
|
||||||
|
rowMap.set(detailRow.key, structuredClone(detailRow));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.values = {
|
||||||
|
...existing.values,
|
||||||
|
...detailRow.values
|
||||||
|
};
|
||||||
|
existing.sourceFactIds = [...new Set([...(existing.sourceFactIds ?? []), ...(detailRow.sourceFactIds ?? [])])];
|
||||||
|
existing.dimensionsSummary = [...new Set([...(existing.dimensionsSummary ?? []), ...(detailRow.dimensionsSummary ?? [])])];
|
||||||
|
}
|
||||||
|
|
||||||
|
merged[surfaceKey] = [...rowMap.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeFinancialPages(
|
||||||
|
base: CompanyFinancialStatementsResponse | null,
|
||||||
|
next: CompanyFinancialStatementsResponse
|
||||||
|
) {
|
||||||
|
if (!base) {
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
const periods = [...base.periods, ...next.periods]
|
||||||
|
.filter((period, index, list) => list.findIndex((item) => item.id === period.id) === index)
|
||||||
|
.sort((left, right) => Date.parse(left.periodEnd ?? left.filingDate) - Date.parse(right.periodEnd ?? right.filingDate));
|
||||||
|
|
||||||
|
const mergeRows = <T extends { key: string; values: Record<string, number | null> }>(rows: T[]) => {
|
||||||
|
const map = new Map<string, T>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const existing = map.get(row.key);
|
||||||
|
if (!existing) {
|
||||||
|
map.set(row.key, structuredClone(row));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.values = {
|
||||||
|
...existing.values,
|
||||||
|
...row.values
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...map.values()];
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...next,
|
||||||
|
periods,
|
||||||
|
statementRows: next.statementRows && base.statementRows
|
||||||
|
? {
|
||||||
|
faithful: mergeRows([...(base.statementRows.faithful ?? []), ...(next.statementRows.faithful ?? [])]),
|
||||||
|
standardized: mergeRows([...(base.statementRows.standardized ?? []), ...(next.statementRows.standardized ?? [])])
|
||||||
|
}
|
||||||
|
: next.statementRows,
|
||||||
|
statementDetails: mergeDetailMaps(base.statementDetails, next.statementDetails),
|
||||||
|
ratioRows: next.ratioRows && base.ratioRows
|
||||||
|
? mergeRows([...(base.ratioRows ?? []), ...(next.ratioRows ?? [])])
|
||||||
|
: next.ratioRows,
|
||||||
|
kpiRows: next.kpiRows && base.kpiRows
|
||||||
|
? mergeRows([...(base.kpiRows ?? []), ...(next.kpiRows ?? [])])
|
||||||
|
: next.kpiRows,
|
||||||
|
trendSeries: next.trendSeries,
|
||||||
|
categories: next.categories,
|
||||||
|
dimensionBreakdown: next.dimensionBreakdown ?? base.dimensionBreakdown
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const __financialPageMergeInternals = {
|
||||||
|
mergeDetailMaps,
|
||||||
|
mergeFinancialPages
|
||||||
|
};
|
||||||
@@ -160,6 +160,28 @@ describe('statement view model', () => {
|
|||||||
expect(child?.kind === 'surface' && child.expanded).toBe(true);
|
expect(child?.kind === 'surface' && child.expanded).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not throw when legacy surface rows are missing source arrays', () => {
|
||||||
|
const malformedRow = {
|
||||||
|
...createSurfaceRow({ key: 'revenue', label: 'Revenue', category: 'revenue', values: { p1: 100 } }),
|
||||||
|
sourceConcepts: undefined,
|
||||||
|
sourceRowKeys: undefined
|
||||||
|
} as unknown as SurfaceFinancialRow;
|
||||||
|
|
||||||
|
const model = buildStatementTree({
|
||||||
|
surfaceKind: 'income_statement',
|
||||||
|
rows: [malformedRow],
|
||||||
|
statementDetails: null,
|
||||||
|
categories: [],
|
||||||
|
searchQuery: 'revenue',
|
||||||
|
expandedRowKeys: new Set()
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(model.sections[0]?.nodes[0]).toMatchObject({
|
||||||
|
kind: 'surface',
|
||||||
|
row: { key: 'revenue' }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps not meaningful rows visible and resolves selections for surface and detail nodes', () => {
|
it('keeps not meaningful rows visible and resolves selections for surface and detail nodes', () => {
|
||||||
const rows = [
|
const rows = [
|
||||||
createSurfaceRow({
|
createSurfaceRow({
|
||||||
|
|||||||
@@ -99,8 +99,8 @@ function searchTextForSurface(row: SurfaceFinancialRow) {
|
|||||||
return [
|
return [
|
||||||
row.label,
|
row.label,
|
||||||
row.key,
|
row.key,
|
||||||
...row.sourceConcepts,
|
...(row.sourceConcepts ?? []),
|
||||||
...row.sourceRowKeys,
|
...(row.sourceRowKeys ?? []),
|
||||||
...(row.warningCodes ?? [])
|
...(row.warningCodes ?? [])
|
||||||
]
|
]
|
||||||
.join(' ')
|
.join(' ')
|
||||||
@@ -115,7 +115,7 @@ function searchTextForDetail(row: DetailFinancialRow) {
|
|||||||
row.conceptKey,
|
row.conceptKey,
|
||||||
row.qname,
|
row.qname,
|
||||||
row.localName,
|
row.localName,
|
||||||
...row.dimensionsSummary
|
...(row.dimensionsSummary ?? [])
|
||||||
]
|
]
|
||||||
.join(' ')
|
.join(' ')
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
|
|||||||
@@ -1811,6 +1811,56 @@ describe('financial taxonomy internals', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('aggregates persisted detail rows when legacy snapshots are missing dimension arrays', () => {
|
||||||
|
const snapshot = {
|
||||||
|
...createSnapshot({
|
||||||
|
filingId: 21,
|
||||||
|
filingType: '10-K',
|
||||||
|
filingDate: '2026-02-22',
|
||||||
|
statement: 'income',
|
||||||
|
periods: [
|
||||||
|
{ id: '2025-fy', periodStart: '2025-01-01', periodEnd: '2025-12-31', periodLabel: '2025 FY' }
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
detail_rows: {
|
||||||
|
income: {
|
||||||
|
revenue: [{
|
||||||
|
key: 'revenue_detail',
|
||||||
|
parentSurfaceKey: 'revenue',
|
||||||
|
label: 'Revenue Detail',
|
||||||
|
conceptKey: 'us-gaap:RevenueDetail',
|
||||||
|
qname: 'us-gaap:RevenueDetail',
|
||||||
|
namespaceUri: 'http://fasb.org/us-gaap/2024',
|
||||||
|
localName: 'RevenueDetail',
|
||||||
|
unit: 'iso4217:USD',
|
||||||
|
values: { '2025-fy': 123_000_000 },
|
||||||
|
sourceFactIds: undefined,
|
||||||
|
isExtension: false,
|
||||||
|
dimensionsSummary: undefined,
|
||||||
|
residualFlag: false
|
||||||
|
} as unknown as FilingTaxonomySnapshotRecord['detail_rows']['income'][string][number]]
|
||||||
|
},
|
||||||
|
balance: {},
|
||||||
|
cash_flow: {},
|
||||||
|
equity: {},
|
||||||
|
comprehensive_income: {}
|
||||||
|
}
|
||||||
|
} satisfies FilingTaxonomySnapshotRecord;
|
||||||
|
|
||||||
|
const rows = __financialTaxonomyInternals.aggregateDetailRows({
|
||||||
|
snapshots: [snapshot],
|
||||||
|
statement: 'income',
|
||||||
|
selectedPeriodIds: new Set(['2025-fy'])
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(rows.revenue).toHaveLength(1);
|
||||||
|
expect(rows.revenue?.[0]).toMatchObject({
|
||||||
|
key: 'revenue_detail',
|
||||||
|
sourceFactIds: [],
|
||||||
|
dimensionsSummary: []
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('builds normalization metadata from snapshot fiscal pack and counts', () => {
|
it('builds normalization metadata from snapshot fiscal pack and counts', () => {
|
||||||
const snapshot = {
|
const snapshot = {
|
||||||
...createSnapshot({
|
...createSnapshot({
|
||||||
|
|||||||
@@ -78,6 +78,49 @@ type FilingDocumentRef = {
|
|||||||
primaryDocument: string | null;
|
primaryDocument: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasRequiredDerivedRowArrays(row: unknown) {
|
||||||
|
return isRecord(row)
|
||||||
|
&& Array.isArray(row.sourceConcepts)
|
||||||
|
&& Array.isArray(row.sourceRowKeys)
|
||||||
|
&& Array.isArray(row.sourceFactIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasRequiredDetailRowArrays(row: unknown) {
|
||||||
|
return isRecord(row)
|
||||||
|
&& Array.isArray(row.sourceFactIds)
|
||||||
|
&& Array.isArray(row.dimensionsSummary);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidStatementBundlePayload(value: unknown): value is StandardizedStatementBundlePayload {
|
||||||
|
if (!isRecord(value) || !Array.isArray(value.rows) || !isRecord(value.detailRows)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value.rows.every((row) => hasRequiredDerivedRowArrays(row))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.values(value.detailRows).every((rows) => (
|
||||||
|
Array.isArray(rows) && rows.every((row) => hasRequiredDetailRowArrays(row))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidRatioBundlePayload(value: unknown): value is Pick<CompanyFinancialStatementsResponse, 'ratioRows' | 'trendSeries' | 'categories'> {
|
||||||
|
return isRecord(value)
|
||||||
|
&& Array.isArray(value.ratioRows)
|
||||||
|
&& value.ratioRows.every((row) => hasRequiredDerivedRowArrays(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidKpiBundlePayload(value: unknown): value is Pick<CompanyFinancialStatementsResponse, 'kpiRows' | 'trendSeries' | 'categories'> {
|
||||||
|
return isRecord(value)
|
||||||
|
&& Array.isArray(value.kpiRows)
|
||||||
|
&& value.kpiRows.every((row) => isRecord(row) && Array.isArray(row.sourceConcepts) && Array.isArray(row.sourceFactIds));
|
||||||
|
}
|
||||||
|
|
||||||
function safeTicker(input: string) {
|
function safeTicker(input: string) {
|
||||||
return input.trim().toUpperCase();
|
return input.trim().toUpperCase();
|
||||||
}
|
}
|
||||||
@@ -350,7 +393,7 @@ function detailConceptIdentity(row: DetailFinancialRow) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function detailMergeKey(row: DetailFinancialRow) {
|
function detailMergeKey(row: DetailFinancialRow) {
|
||||||
const dimensionsKey = [...row.dimensionsSummary]
|
const dimensionsKey = [...(row.dimensionsSummary ?? [])]
|
||||||
.map((value) => value.trim().toLowerCase())
|
.map((value) => value.trim().toLowerCase())
|
||||||
.filter((value) => value.length > 0)
|
.filter((value) => value.length > 0)
|
||||||
.sort((left, right) => left.localeCompare(right))
|
.sort((left, right) => left.localeCompare(right))
|
||||||
@@ -713,10 +756,9 @@ async function buildStatementSurfaceBundle(input: {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
cached
|
cached
|
||||||
&& Array.isArray((cached as Partial<StandardizedStatementBundlePayload>).rows)
|
&& isValidStatementBundlePayload(cached)
|
||||||
&& typeof (cached as Partial<StandardizedStatementBundlePayload>).detailRows === 'object'
|
|
||||||
) {
|
) {
|
||||||
return cached as StandardizedStatementBundlePayload;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
const statement = surfaceToStatementKind(input.surfaceKind);
|
const statement = surfaceToStatementKind(input.surfaceKind);
|
||||||
@@ -794,8 +836,8 @@ async function buildRatioSurfaceBundle(input: {
|
|||||||
snapshots: input.snapshots
|
snapshots: input.snapshots
|
||||||
});
|
});
|
||||||
|
|
||||||
if (cached) {
|
if (cached && isValidRatioBundlePayload(cached)) {
|
||||||
return cached as Pick<CompanyFinancialStatementsResponse, 'ratioRows' | 'trendSeries' | 'categories'>;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pricesByDate = await getHistoricalClosingPrices(input.ticker, input.periods.map((period) => latestPeriodDate(period)));
|
const pricesByDate = await getHistoricalClosingPrices(input.ticker, input.periods.map((period) => latestPeriodDate(period)));
|
||||||
@@ -846,8 +888,8 @@ async function buildKpiSurfaceBundle(input: {
|
|||||||
snapshots: input.snapshots
|
snapshots: input.snapshots
|
||||||
});
|
});
|
||||||
|
|
||||||
if (cached) {
|
if (cached && isValidKpiBundlePayload(cached)) {
|
||||||
return cached as Pick<CompanyFinancialStatementsResponse, 'kpiRows' | 'trendSeries' | 'categories'>;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
const persistedRows = aggregatePersistedKpiRows({
|
const persistedRows = aggregatePersistedKpiRows({
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { db, getSqliteClient } from '@/lib/server/db';
|
|||||||
import { withFinancialIngestionSchemaRetry } from '@/lib/server/db/financial-ingestion-schema';
|
import { withFinancialIngestionSchemaRetry } from '@/lib/server/db/financial-ingestion-schema';
|
||||||
import { companyFinancialBundle } from '@/lib/server/db/schema';
|
import { companyFinancialBundle } from '@/lib/server/db/schema';
|
||||||
|
|
||||||
export const CURRENT_COMPANY_FINANCIAL_BUNDLE_VERSION = 14;
|
export const CURRENT_COMPANY_FINANCIAL_BUNDLE_VERSION = 15;
|
||||||
|
|
||||||
export type CompanyFinancialBundleRecord = {
|
export type CompanyFinancialBundleRecord = {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
291
lib/server/repos/filing-taxonomy.test.ts
Normal file
291
lib/server/repos/filing-taxonomy.test.ts
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
import { __filingTaxonomyInternals } from './filing-taxonomy';
|
||||||
|
|
||||||
|
describe('filing taxonomy snapshot normalization', () => {
|
||||||
|
it('normalizes legacy snake_case nested snapshot payloads in toSnapshotRecord', () => {
|
||||||
|
const record = __filingTaxonomyInternals.toSnapshotRecord({
|
||||||
|
id: 1,
|
||||||
|
filing_id: 10,
|
||||||
|
ticker: 'MSFT',
|
||||||
|
filing_date: '2026-01-28',
|
||||||
|
filing_type: '10-Q',
|
||||||
|
parse_status: 'ready',
|
||||||
|
parse_error: null,
|
||||||
|
source: 'xbrl_instance',
|
||||||
|
parser_engine: 'fiscal-xbrl',
|
||||||
|
parser_version: '0.1.0',
|
||||||
|
taxonomy_regime: 'us-gaap',
|
||||||
|
fiscal_pack: 'core',
|
||||||
|
periods: [{
|
||||||
|
id: 'fy-2025',
|
||||||
|
filing_id: 10,
|
||||||
|
accession_number: '0001',
|
||||||
|
filing_date: '2026-01-28',
|
||||||
|
period_start: '2025-01-01',
|
||||||
|
period_end: '2025-12-31',
|
||||||
|
filing_type: '10-Q',
|
||||||
|
period_label: 'FY 2025'
|
||||||
|
}],
|
||||||
|
faithful_rows: {
|
||||||
|
income: [{
|
||||||
|
key: 'revenue',
|
||||||
|
label: 'Revenue',
|
||||||
|
concept_key: 'us-gaap:Revenue',
|
||||||
|
qname: 'us-gaap:Revenue',
|
||||||
|
namespace_uri: 'http://fasb.org/us-gaap/2025',
|
||||||
|
local_name: 'Revenue',
|
||||||
|
is_extension: false,
|
||||||
|
statement: 'income',
|
||||||
|
role_uri: 'income',
|
||||||
|
order: 10,
|
||||||
|
depth: 0,
|
||||||
|
parent_key: null,
|
||||||
|
values: { 'fy-2025': 10 },
|
||||||
|
units: { 'fy-2025': 'iso4217:USD' },
|
||||||
|
has_dimensions: false,
|
||||||
|
source_fact_ids: [1]
|
||||||
|
}],
|
||||||
|
balance: [],
|
||||||
|
cash_flow: [],
|
||||||
|
equity: [],
|
||||||
|
comprehensive_income: []
|
||||||
|
},
|
||||||
|
statement_rows: null,
|
||||||
|
surface_rows: {
|
||||||
|
income: [{
|
||||||
|
key: 'revenue',
|
||||||
|
label: 'Revenue',
|
||||||
|
category: 'revenue',
|
||||||
|
template_section: 'revenue',
|
||||||
|
order: 10,
|
||||||
|
unit: 'currency',
|
||||||
|
values: { 'fy-2025': 10 },
|
||||||
|
source_concepts: ['us-gaap:Revenue'],
|
||||||
|
source_row_keys: ['revenue'],
|
||||||
|
source_fact_ids: [1],
|
||||||
|
formula_key: null,
|
||||||
|
has_dimensions: false,
|
||||||
|
resolved_source_row_keys: { 'fy-2025': 'revenue' },
|
||||||
|
statement: 'income',
|
||||||
|
detail_count: 1,
|
||||||
|
resolution_method: 'direct',
|
||||||
|
confidence: 'high',
|
||||||
|
warning_codes: ['legacy_surface']
|
||||||
|
}],
|
||||||
|
balance: [],
|
||||||
|
cash_flow: [],
|
||||||
|
equity: [],
|
||||||
|
comprehensive_income: []
|
||||||
|
},
|
||||||
|
detail_rows: {
|
||||||
|
income: {
|
||||||
|
revenue: [{
|
||||||
|
key: 'revenue_detail',
|
||||||
|
parent_surface_key: 'revenue',
|
||||||
|
label: 'Revenue Detail',
|
||||||
|
concept_key: 'us-gaap:RevenueDetail',
|
||||||
|
qname: 'us-gaap:RevenueDetail',
|
||||||
|
namespace_uri: 'http://fasb.org/us-gaap/2025',
|
||||||
|
local_name: 'RevenueDetail',
|
||||||
|
unit: 'iso4217:USD',
|
||||||
|
values: { 'fy-2025': 10 },
|
||||||
|
source_fact_ids: [2],
|
||||||
|
is_extension: false,
|
||||||
|
dimensions_summary: ['region:americas'],
|
||||||
|
residual_flag: false
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
balance: {},
|
||||||
|
cash_flow: {},
|
||||||
|
equity: {},
|
||||||
|
comprehensive_income: {}
|
||||||
|
},
|
||||||
|
kpi_rows: [{
|
||||||
|
key: 'cloud_growth',
|
||||||
|
label: 'Cloud Growth',
|
||||||
|
category: 'operating_kpi',
|
||||||
|
unit: 'percent',
|
||||||
|
order: 10,
|
||||||
|
segment: null,
|
||||||
|
axis: null,
|
||||||
|
member: null,
|
||||||
|
values: { 'fy-2025': 0.25 },
|
||||||
|
source_concepts: ['msft:CloudGrowth'],
|
||||||
|
source_fact_ids: [3],
|
||||||
|
provenance_type: 'taxonomy',
|
||||||
|
has_dimensions: false
|
||||||
|
}],
|
||||||
|
derived_metrics: null,
|
||||||
|
validation_result: null,
|
||||||
|
normalization_summary: {
|
||||||
|
surface_row_count: 1,
|
||||||
|
detail_row_count: 1,
|
||||||
|
kpi_row_count: 1,
|
||||||
|
unmapped_row_count: 0,
|
||||||
|
material_unmapped_row_count: 0,
|
||||||
|
warnings: ['legacy_warning']
|
||||||
|
},
|
||||||
|
facts_count: 3,
|
||||||
|
concepts_count: 3,
|
||||||
|
dimensions_count: 1,
|
||||||
|
created_at: '2026-01-28T00:00:00.000Z',
|
||||||
|
updated_at: '2026-01-28T00:00:00.000Z'
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(record.periods[0]).toMatchObject({
|
||||||
|
filingId: 10,
|
||||||
|
accessionNumber: '0001',
|
||||||
|
filingDate: '2026-01-28',
|
||||||
|
periodStart: '2025-01-01',
|
||||||
|
periodEnd: '2025-12-31',
|
||||||
|
periodLabel: 'FY 2025'
|
||||||
|
});
|
||||||
|
expect(record.faithful_rows.income[0]).toMatchObject({
|
||||||
|
conceptKey: 'us-gaap:Revenue',
|
||||||
|
namespaceUri: 'http://fasb.org/us-gaap/2025',
|
||||||
|
localName: 'Revenue',
|
||||||
|
roleUri: 'income',
|
||||||
|
parentKey: null,
|
||||||
|
hasDimensions: false,
|
||||||
|
sourceFactIds: [1]
|
||||||
|
});
|
||||||
|
expect(record.surface_rows.income[0]).toMatchObject({
|
||||||
|
templateSection: 'revenue',
|
||||||
|
sourceConcepts: ['us-gaap:Revenue'],
|
||||||
|
sourceRowKeys: ['revenue'],
|
||||||
|
sourceFactIds: [1],
|
||||||
|
formulaKey: null,
|
||||||
|
hasDimensions: false,
|
||||||
|
resolvedSourceRowKeys: { 'fy-2025': 'revenue' },
|
||||||
|
detailCount: 1,
|
||||||
|
resolutionMethod: 'direct',
|
||||||
|
warningCodes: ['legacy_surface']
|
||||||
|
});
|
||||||
|
expect(record.detail_rows.income.revenue?.[0]).toMatchObject({
|
||||||
|
parentSurfaceKey: 'revenue',
|
||||||
|
conceptKey: 'us-gaap:RevenueDetail',
|
||||||
|
namespaceUri: 'http://fasb.org/us-gaap/2025',
|
||||||
|
sourceFactIds: [2],
|
||||||
|
dimensionsSummary: ['region:americas'],
|
||||||
|
residualFlag: false
|
||||||
|
});
|
||||||
|
expect(record.kpi_rows[0]).toMatchObject({
|
||||||
|
sourceConcepts: ['msft:CloudGrowth'],
|
||||||
|
sourceFactIds: [3],
|
||||||
|
provenanceType: 'taxonomy',
|
||||||
|
hasDimensions: false
|
||||||
|
});
|
||||||
|
expect(record.normalization_summary).toEqual({
|
||||||
|
surfaceRowCount: 1,
|
||||||
|
detailRowCount: 1,
|
||||||
|
kpiRowCount: 1,
|
||||||
|
unmappedRowCount: 0,
|
||||||
|
materialUnmappedRowCount: 0,
|
||||||
|
warnings: ['legacy_warning']
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps mixed camelCase and snake_case payloads compatible', () => {
|
||||||
|
const normalized = __filingTaxonomyInternals.normalizeFilingTaxonomySnapshotPayload({
|
||||||
|
periods: [{
|
||||||
|
id: 'fy-2025',
|
||||||
|
filingId: 10,
|
||||||
|
accessionNumber: '0001',
|
||||||
|
filingDate: '2026-01-28',
|
||||||
|
periodStart: '2025-01-01',
|
||||||
|
periodEnd: '2025-12-31',
|
||||||
|
filingType: '10-K',
|
||||||
|
periodLabel: 'FY 2025'
|
||||||
|
}],
|
||||||
|
faithful_rows: {
|
||||||
|
income: [{
|
||||||
|
key: 'revenue',
|
||||||
|
label: 'Revenue',
|
||||||
|
conceptKey: 'us-gaap:Revenue',
|
||||||
|
qname: 'us-gaap:Revenue',
|
||||||
|
namespaceUri: 'http://fasb.org/us-gaap/2025',
|
||||||
|
localName: 'Revenue',
|
||||||
|
isExtension: false,
|
||||||
|
statement: 'income',
|
||||||
|
roleUri: 'income',
|
||||||
|
order: 10,
|
||||||
|
depth: 0,
|
||||||
|
parentKey: null,
|
||||||
|
values: { 'fy-2025': 10 },
|
||||||
|
units: { 'fy-2025': 'iso4217:USD' },
|
||||||
|
hasDimensions: false,
|
||||||
|
sourceFactIds: [1]
|
||||||
|
}],
|
||||||
|
balance: [],
|
||||||
|
cash_flow: [],
|
||||||
|
equity: [],
|
||||||
|
comprehensive_income: []
|
||||||
|
},
|
||||||
|
statement_rows: null,
|
||||||
|
surface_rows: {
|
||||||
|
income: [{
|
||||||
|
key: 'revenue',
|
||||||
|
label: 'Revenue',
|
||||||
|
category: 'revenue',
|
||||||
|
order: 10,
|
||||||
|
unit: 'currency',
|
||||||
|
values: { 'fy-2025': 10 },
|
||||||
|
source_concepts: ['us-gaap:Revenue'],
|
||||||
|
source_row_keys: ['revenue'],
|
||||||
|
source_fact_ids: [1],
|
||||||
|
formula_key: null,
|
||||||
|
has_dimensions: false,
|
||||||
|
resolved_source_row_keys: { 'fy-2025': 'revenue' }
|
||||||
|
}],
|
||||||
|
balance: [],
|
||||||
|
cash_flow: [],
|
||||||
|
equity: [],
|
||||||
|
comprehensive_income: []
|
||||||
|
},
|
||||||
|
detail_rows: {
|
||||||
|
income: {
|
||||||
|
revenue: [{
|
||||||
|
key: 'revenue_detail',
|
||||||
|
parentSurfaceKey: 'revenue',
|
||||||
|
label: 'Revenue Detail',
|
||||||
|
conceptKey: 'us-gaap:RevenueDetail',
|
||||||
|
qname: 'us-gaap:RevenueDetail',
|
||||||
|
namespaceUri: 'http://fasb.org/us-gaap/2025',
|
||||||
|
localName: 'RevenueDetail',
|
||||||
|
unit: 'iso4217:USD',
|
||||||
|
values: { 'fy-2025': 10 },
|
||||||
|
sourceFactIds: [2],
|
||||||
|
isExtension: false,
|
||||||
|
dimensionsSummary: [],
|
||||||
|
residualFlag: false
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
balance: {},
|
||||||
|
cash_flow: {},
|
||||||
|
equity: {},
|
||||||
|
comprehensive_income: {}
|
||||||
|
},
|
||||||
|
kpi_rows: [],
|
||||||
|
normalization_summary: {
|
||||||
|
surfaceRowCount: 1,
|
||||||
|
detail_row_count: 1,
|
||||||
|
kpiRowCount: 0,
|
||||||
|
unmapped_row_count: 0,
|
||||||
|
materialUnmappedRowCount: 0,
|
||||||
|
warnings: []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(normalized.periods[0]?.filingId).toBe(10);
|
||||||
|
expect(normalized.surface_rows.income[0]?.sourceConcepts).toEqual(['us-gaap:Revenue']);
|
||||||
|
expect(normalized.detail_rows.income.revenue?.[0]?.parentSurfaceKey).toBe('revenue');
|
||||||
|
expect(normalized.normalization_summary).toEqual({
|
||||||
|
surfaceRowCount: 1,
|
||||||
|
detailRowCount: 1,
|
||||||
|
kpiRowCount: 0,
|
||||||
|
unmappedRowCount: 0,
|
||||||
|
materialUnmappedRowCount: 0,
|
||||||
|
warnings: []
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { and, desc, eq, gte, inArray, lt, sql } from 'drizzle-orm';
|
import { and, desc, eq, gte, inArray, lt, sql } from 'drizzle-orm';
|
||||||
import type {
|
import type {
|
||||||
|
DetailFinancialRow,
|
||||||
Filing,
|
Filing,
|
||||||
FinancialStatementKind,
|
FinancialStatementKind,
|
||||||
MetricValidationResult,
|
MetricValidationResult,
|
||||||
@@ -283,6 +284,18 @@ export type UpsertFilingTaxonomySnapshotInput = {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const FINANCIAL_STATEMENT_KINDS = [
|
||||||
|
'income',
|
||||||
|
'balance',
|
||||||
|
'cash_flow',
|
||||||
|
'equity',
|
||||||
|
'comprehensive_income'
|
||||||
|
] as const satisfies FinancialStatementKind[];
|
||||||
|
|
||||||
|
type StatementRowMap = Record<FinancialStatementKind, TaxonomyStatementRow[]>;
|
||||||
|
type SurfaceRowMap = Record<FinancialStatementKind, SurfaceFinancialRow[]>;
|
||||||
|
type DetailRowMap = Record<FinancialStatementKind, SurfaceDetailMap>;
|
||||||
|
|
||||||
function tenYearsAgoIso() {
|
function tenYearsAgoIso() {
|
||||||
const date = new Date();
|
const date = new Date();
|
||||||
date.setUTCFullYear(date.getUTCFullYear() - 10);
|
date.setUTCFullYear(date.getUTCFullYear() - 10);
|
||||||
@@ -310,7 +323,394 @@ function asNumericText(value: number | null) {
|
|||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyStatementRows(): Record<FinancialStatementKind, TaxonomyStatementRow[]> {
|
function asObject(value: unknown) {
|
||||||
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asString(value: unknown) {
|
||||||
|
return typeof value === 'string' ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asNullableString(value: unknown) {
|
||||||
|
return typeof value === 'string'
|
||||||
|
? value
|
||||||
|
: value === null
|
||||||
|
? null
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asBoolean(value: unknown) {
|
||||||
|
return typeof value === 'boolean' ? value : Boolean(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function asStatementKind(value: unknown): FinancialStatementKind | null {
|
||||||
|
return value === 'income'
|
||||||
|
|| value === 'balance'
|
||||||
|
|| value === 'cash_flow'
|
||||||
|
|| value === 'equity'
|
||||||
|
|| value === 'comprehensive_income'
|
||||||
|
? value
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNumberMap(value: unknown) {
|
||||||
|
const object = asObject(value);
|
||||||
|
if (!object) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(object).map(([key, entry]) => [key, asNumber(entry)])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNullableStringMap(value: unknown) {
|
||||||
|
const object = asObject(value);
|
||||||
|
if (!object) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(object).map(([key, entry]) => [key, asNullableString(entry)])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStringArray(value: unknown) {
|
||||||
|
return Array.isArray(value)
|
||||||
|
? value.filter((entry): entry is string => typeof entry === 'string')
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNumberArray(value: unknown) {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
.map((entry) => asNumber(entry))
|
||||||
|
.filter((entry): entry is number => entry !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePeriods(value: unknown): FilingTaxonomyPeriod[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
.map((entry) => {
|
||||||
|
const row = asObject(entry);
|
||||||
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = asString(row.id);
|
||||||
|
const filingId = asNumber(row.filingId ?? row.filing_id);
|
||||||
|
const accessionNumber = asString(row.accessionNumber ?? row.accession_number);
|
||||||
|
const filingDate = asString(row.filingDate ?? row.filing_date);
|
||||||
|
const filingType = row.filingType === '10-K' || row.filing_type === '10-K'
|
||||||
|
? '10-K'
|
||||||
|
: row.filingType === '10-Q' || row.filing_type === '10-Q'
|
||||||
|
? '10-Q'
|
||||||
|
: null;
|
||||||
|
const periodLabel = asString(row.periodLabel ?? row.period_label);
|
||||||
|
|
||||||
|
if (!id || filingId === null || !accessionNumber || !filingDate || !filingType || !periodLabel) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
filingId,
|
||||||
|
accessionNumber,
|
||||||
|
filingDate,
|
||||||
|
periodStart: asNullableString(row.periodStart ?? row.period_start),
|
||||||
|
periodEnd: asNullableString(row.periodEnd ?? row.period_end),
|
||||||
|
filingType,
|
||||||
|
periodLabel
|
||||||
|
} satisfies FilingTaxonomyPeriod;
|
||||||
|
})
|
||||||
|
.filter((entry): entry is FilingTaxonomyPeriod => entry !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStatementRows(
|
||||||
|
value: unknown,
|
||||||
|
fallbackRows: StatementRowMap = emptyStatementRows()
|
||||||
|
): StatementRowMap {
|
||||||
|
const object = asObject(value);
|
||||||
|
if (!object) {
|
||||||
|
return fallbackRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = emptyStatementRows();
|
||||||
|
for (const statement of FINANCIAL_STATEMENT_KINDS) {
|
||||||
|
const rows = Array.isArray(object[statement]) ? object[statement] : [];
|
||||||
|
normalized[statement] = rows
|
||||||
|
.map((entry) => {
|
||||||
|
const row = asObject(entry);
|
||||||
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = asString(row.key) ?? asString(row.conceptKey ?? row.concept_key);
|
||||||
|
const label = asString(row.label);
|
||||||
|
const conceptKey = asString(row.conceptKey ?? row.concept_key);
|
||||||
|
const qname = asString(row.qname);
|
||||||
|
const namespaceUri = asString(row.namespaceUri ?? row.namespace_uri);
|
||||||
|
const localName = asString(row.localName ?? row.local_name);
|
||||||
|
if (!key || !label || !conceptKey || !qname || !namespaceUri || !localName) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
label,
|
||||||
|
conceptKey,
|
||||||
|
qname,
|
||||||
|
namespaceUri,
|
||||||
|
localName,
|
||||||
|
isExtension: asBoolean(row.isExtension ?? row.is_extension),
|
||||||
|
statement: asStatementKind(row.statement) ?? statement,
|
||||||
|
roleUri: asNullableString(row.roleUri ?? row.role_uri),
|
||||||
|
order: asNumber(row.order) ?? Number.MAX_SAFE_INTEGER,
|
||||||
|
depth: asNumber(row.depth) ?? 0,
|
||||||
|
parentKey: asNullableString(row.parentKey ?? row.parent_key),
|
||||||
|
values: normalizeNumberMap(row.values),
|
||||||
|
units: normalizeNullableStringMap(row.units),
|
||||||
|
hasDimensions: asBoolean(row.hasDimensions ?? row.has_dimensions),
|
||||||
|
sourceFactIds: normalizeNumberArray(row.sourceFactIds ?? row.source_fact_ids)
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((entry): entry is TaxonomyStatementRow => entry !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSurfaceRows(
|
||||||
|
value: unknown,
|
||||||
|
fallbackRows: SurfaceRowMap = emptySurfaceRows()
|
||||||
|
): SurfaceRowMap {
|
||||||
|
const object = asObject(value);
|
||||||
|
if (!object) {
|
||||||
|
return fallbackRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = emptySurfaceRows();
|
||||||
|
for (const statement of FINANCIAL_STATEMENT_KINDS) {
|
||||||
|
const rows = Array.isArray(object[statement]) ? object[statement] : [];
|
||||||
|
normalized[statement] = rows
|
||||||
|
.map((entry) => {
|
||||||
|
const row = asObject(entry);
|
||||||
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = asString(row.key);
|
||||||
|
const label = asString(row.label);
|
||||||
|
const category = asString(row.category);
|
||||||
|
const unit = asString(row.unit);
|
||||||
|
if (!key || !label || !category || !unit) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedStatement = asStatementKind(row.statement);
|
||||||
|
const resolutionMethod = row.resolutionMethod ?? row.resolution_method;
|
||||||
|
const confidence = row.confidence;
|
||||||
|
const normalizedRow: SurfaceFinancialRow = {
|
||||||
|
key,
|
||||||
|
label,
|
||||||
|
category: category as SurfaceFinancialRow['category'],
|
||||||
|
order: asNumber(row.order) ?? Number.MAX_SAFE_INTEGER,
|
||||||
|
unit: unit as SurfaceFinancialRow['unit'],
|
||||||
|
values: normalizeNumberMap(row.values),
|
||||||
|
sourceConcepts: normalizeStringArray(row.sourceConcepts ?? row.source_concepts),
|
||||||
|
sourceRowKeys: normalizeStringArray(row.sourceRowKeys ?? row.source_row_keys),
|
||||||
|
sourceFactIds: normalizeNumberArray(row.sourceFactIds ?? row.source_fact_ids),
|
||||||
|
formulaKey: asNullableString(row.formulaKey ?? row.formula_key),
|
||||||
|
hasDimensions: asBoolean(row.hasDimensions ?? row.has_dimensions),
|
||||||
|
resolvedSourceRowKeys: normalizeNullableStringMap(row.resolvedSourceRowKeys ?? row.resolved_source_row_keys)
|
||||||
|
};
|
||||||
|
|
||||||
|
const templateSection = asString(row.templateSection ?? row.template_section);
|
||||||
|
if (templateSection) {
|
||||||
|
normalizedRow.templateSection = templateSection as SurfaceFinancialRow['templateSection'];
|
||||||
|
}
|
||||||
|
if (normalizedStatement === 'income' || normalizedStatement === 'balance' || normalizedStatement === 'cash_flow') {
|
||||||
|
normalizedRow.statement = normalizedStatement;
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailCount = asNumber(row.detailCount ?? row.detail_count);
|
||||||
|
if (detailCount !== null) {
|
||||||
|
normalizedRow.detailCount = detailCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
resolutionMethod === 'direct'
|
||||||
|
|| resolutionMethod === 'surface_bridge'
|
||||||
|
|| resolutionMethod === 'formula_derived'
|
||||||
|
|| resolutionMethod === 'not_meaningful'
|
||||||
|
) {
|
||||||
|
normalizedRow.resolutionMethod = resolutionMethod;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (confidence === 'high' || confidence === 'medium' || confidence === 'low') {
|
||||||
|
normalizedRow.confidence = confidence;
|
||||||
|
}
|
||||||
|
|
||||||
|
const warningCodes = normalizeStringArray(row.warningCodes ?? row.warning_codes);
|
||||||
|
if (warningCodes.length > 0) {
|
||||||
|
normalizedRow.warningCodes = warningCodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedRow;
|
||||||
|
})
|
||||||
|
.filter((entry): entry is SurfaceFinancialRow => entry !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDetailRows(
|
||||||
|
value: unknown,
|
||||||
|
fallbackRows: DetailRowMap = emptyDetailRows()
|
||||||
|
): DetailRowMap {
|
||||||
|
const object = asObject(value);
|
||||||
|
if (!object) {
|
||||||
|
return fallbackRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = emptyDetailRows();
|
||||||
|
for (const statement of FINANCIAL_STATEMENT_KINDS) {
|
||||||
|
const groups = asObject(object[statement]) ?? {};
|
||||||
|
normalized[statement] = Object.fromEntries(
|
||||||
|
Object.entries(groups).map(([surfaceKey, rows]) => {
|
||||||
|
const normalizedRows = Array.isArray(rows)
|
||||||
|
? rows
|
||||||
|
.map((entry) => {
|
||||||
|
const row = asObject(entry);
|
||||||
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = asString(row.key) ?? asString(row.conceptKey ?? row.concept_key);
|
||||||
|
const label = asString(row.label);
|
||||||
|
const conceptKey = asString(row.conceptKey ?? row.concept_key);
|
||||||
|
const qname = asString(row.qname);
|
||||||
|
const namespaceUri = asString(row.namespaceUri ?? row.namespace_uri);
|
||||||
|
const localName = asString(row.localName ?? row.local_name);
|
||||||
|
if (!key || !label || !conceptKey || !qname || !namespaceUri || !localName) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
parentSurfaceKey: asString(row.parentSurfaceKey ?? row.parent_surface_key) ?? surfaceKey,
|
||||||
|
label,
|
||||||
|
conceptKey,
|
||||||
|
qname,
|
||||||
|
namespaceUri,
|
||||||
|
localName,
|
||||||
|
unit: asNullableString(row.unit),
|
||||||
|
values: normalizeNumberMap(row.values),
|
||||||
|
sourceFactIds: normalizeNumberArray(row.sourceFactIds ?? row.source_fact_ids),
|
||||||
|
isExtension: asBoolean(row.isExtension ?? row.is_extension),
|
||||||
|
dimensionsSummary: normalizeStringArray(row.dimensionsSummary ?? row.dimensions_summary),
|
||||||
|
residualFlag: asBoolean(row.residualFlag ?? row.residual_flag)
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((entry): entry is DetailFinancialRow => entry !== null)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return [surfaceKey, normalizedRows];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeKpiRows(value: unknown) {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
.map((entry) => {
|
||||||
|
const row = asObject(entry);
|
||||||
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = asString(row.key);
|
||||||
|
const label = asString(row.label);
|
||||||
|
const category = asString(row.category);
|
||||||
|
const unit = asString(row.unit);
|
||||||
|
const provenanceType = row.provenanceType ?? row.provenance_type;
|
||||||
|
if (!key || !label || !category || !unit || (provenanceType !== 'taxonomy' && provenanceType !== 'structured_note')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
label,
|
||||||
|
category: category as StructuredKpiRow['category'],
|
||||||
|
unit: unit as StructuredKpiRow['unit'],
|
||||||
|
order: asNumber(row.order) ?? Number.MAX_SAFE_INTEGER,
|
||||||
|
segment: asNullableString(row.segment),
|
||||||
|
axis: asNullableString(row.axis),
|
||||||
|
member: asNullableString(row.member),
|
||||||
|
values: normalizeNumberMap(row.values),
|
||||||
|
sourceConcepts: normalizeStringArray(row.sourceConcepts ?? row.source_concepts),
|
||||||
|
sourceFactIds: normalizeNumberArray(row.sourceFactIds ?? row.source_fact_ids),
|
||||||
|
provenanceType,
|
||||||
|
hasDimensions: asBoolean(row.hasDimensions ?? row.has_dimensions)
|
||||||
|
} satisfies StructuredKpiRow;
|
||||||
|
})
|
||||||
|
.filter((entry): entry is StructuredKpiRow => entry !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNormalizationSummary(value: unknown) {
|
||||||
|
const row = asObject(value);
|
||||||
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
surfaceRowCount: asNumber(row.surfaceRowCount ?? row.surface_row_count) ?? 0,
|
||||||
|
detailRowCount: asNumber(row.detailRowCount ?? row.detail_row_count) ?? 0,
|
||||||
|
kpiRowCount: asNumber(row.kpiRowCount ?? row.kpi_row_count) ?? 0,
|
||||||
|
unmappedRowCount: asNumber(row.unmappedRowCount ?? row.unmapped_row_count) ?? 0,
|
||||||
|
materialUnmappedRowCount: asNumber(row.materialUnmappedRowCount ?? row.material_unmapped_row_count) ?? 0,
|
||||||
|
warnings: normalizeStringArray(row.warnings)
|
||||||
|
} satisfies NormalizationSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeFilingTaxonomySnapshotPayload(input: {
|
||||||
|
periods: unknown;
|
||||||
|
faithful_rows: unknown;
|
||||||
|
statement_rows: unknown;
|
||||||
|
surface_rows: unknown;
|
||||||
|
detail_rows: unknown;
|
||||||
|
kpi_rows: unknown;
|
||||||
|
normalization_summary: unknown;
|
||||||
|
}) {
|
||||||
|
const faithfulRows = normalizeStatementRows(input.faithful_rows);
|
||||||
|
const statementRows = normalizeStatementRows(input.statement_rows, faithfulRows);
|
||||||
|
|
||||||
|
return {
|
||||||
|
periods: normalizePeriods(input.periods),
|
||||||
|
faithful_rows: faithfulRows,
|
||||||
|
statement_rows: statementRows,
|
||||||
|
surface_rows: normalizeSurfaceRows(input.surface_rows),
|
||||||
|
detail_rows: normalizeDetailRows(input.detail_rows),
|
||||||
|
kpi_rows: normalizeKpiRows(input.kpi_rows),
|
||||||
|
normalization_summary: normalizeNormalizationSummary(input.normalization_summary)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyStatementRows(): StatementRowMap {
|
||||||
return {
|
return {
|
||||||
income: [],
|
income: [],
|
||||||
balance: [],
|
balance: [],
|
||||||
@@ -320,7 +720,7 @@ function emptyStatementRows(): Record<FinancialStatementKind, TaxonomyStatementR
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptySurfaceRows(): Record<FinancialStatementKind, SurfaceFinancialRow[]> {
|
function emptySurfaceRows(): SurfaceRowMap {
|
||||||
return {
|
return {
|
||||||
income: [],
|
income: [],
|
||||||
balance: [],
|
balance: [],
|
||||||
@@ -330,7 +730,7 @@ function emptySurfaceRows(): Record<FinancialStatementKind, SurfaceFinancialRow[
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyDetailRows(): Record<FinancialStatementKind, SurfaceDetailMap> {
|
function emptyDetailRows(): DetailRowMap {
|
||||||
return {
|
return {
|
||||||
income: {},
|
income: {},
|
||||||
balance: {},
|
balance: {},
|
||||||
@@ -341,7 +741,15 @@ function emptyDetailRows(): Record<FinancialStatementKind, SurfaceDetailMap> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toSnapshotRecord(row: typeof filingTaxonomySnapshot.$inferSelect): FilingTaxonomySnapshotRecord {
|
function toSnapshotRecord(row: typeof filingTaxonomySnapshot.$inferSelect): FilingTaxonomySnapshotRecord {
|
||||||
const faithfulRows = row.faithful_rows ?? row.statement_rows ?? emptyStatementRows();
|
const normalized = normalizeFilingTaxonomySnapshotPayload({
|
||||||
|
periods: row.periods,
|
||||||
|
faithful_rows: row.faithful_rows,
|
||||||
|
statement_rows: row.statement_rows,
|
||||||
|
surface_rows: row.surface_rows,
|
||||||
|
detail_rows: row.detail_rows,
|
||||||
|
kpi_rows: row.kpi_rows,
|
||||||
|
normalization_summary: row.normalization_summary
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -356,15 +764,15 @@ function toSnapshotRecord(row: typeof filingTaxonomySnapshot.$inferSelect): Fili
|
|||||||
parser_version: row.parser_version,
|
parser_version: row.parser_version,
|
||||||
taxonomy_regime: row.taxonomy_regime,
|
taxonomy_regime: row.taxonomy_regime,
|
||||||
fiscal_pack: row.fiscal_pack,
|
fiscal_pack: row.fiscal_pack,
|
||||||
periods: row.periods ?? [],
|
periods: normalized.periods,
|
||||||
faithful_rows: faithfulRows,
|
faithful_rows: normalized.faithful_rows,
|
||||||
statement_rows: faithfulRows,
|
statement_rows: normalized.statement_rows,
|
||||||
surface_rows: row.surface_rows ?? emptySurfaceRows(),
|
surface_rows: normalized.surface_rows,
|
||||||
detail_rows: row.detail_rows ?? emptyDetailRows(),
|
detail_rows: normalized.detail_rows,
|
||||||
kpi_rows: row.kpi_rows ?? [],
|
kpi_rows: normalized.kpi_rows,
|
||||||
derived_metrics: row.derived_metrics ?? null,
|
derived_metrics: row.derived_metrics ?? null,
|
||||||
validation_result: row.validation_result ?? null,
|
validation_result: row.validation_result ?? null,
|
||||||
normalization_summary: row.normalization_summary ?? null,
|
normalization_summary: normalized.normalization_summary,
|
||||||
facts_count: row.facts_count,
|
facts_count: row.facts_count,
|
||||||
concepts_count: row.concepts_count,
|
concepts_count: row.concepts_count,
|
||||||
dimensions_count: row.dimensions_count,
|
dimensions_count: row.dimensions_count,
|
||||||
@@ -552,6 +960,7 @@ export async function listFilingTaxonomyMetricValidations(snapshotId: number) {
|
|||||||
|
|
||||||
export async function upsertFilingTaxonomySnapshot(input: UpsertFilingTaxonomySnapshotInput) {
|
export async function upsertFilingTaxonomySnapshot(input: UpsertFilingTaxonomySnapshotInput) {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
const normalized = normalizeFilingTaxonomySnapshotPayload(input);
|
||||||
|
|
||||||
const [saved] = await withFinancialIngestionSchemaRetry({
|
const [saved] = await withFinancialIngestionSchemaRetry({
|
||||||
client: getSqliteClient(),
|
client: getSqliteClient(),
|
||||||
@@ -570,15 +979,15 @@ export async function upsertFilingTaxonomySnapshot(input: UpsertFilingTaxonomySn
|
|||||||
parser_version: input.parser_version,
|
parser_version: input.parser_version,
|
||||||
taxonomy_regime: input.taxonomy_regime,
|
taxonomy_regime: input.taxonomy_regime,
|
||||||
fiscal_pack: input.fiscal_pack,
|
fiscal_pack: input.fiscal_pack,
|
||||||
periods: input.periods,
|
periods: normalized.periods,
|
||||||
faithful_rows: input.faithful_rows,
|
faithful_rows: normalized.faithful_rows,
|
||||||
statement_rows: input.statement_rows,
|
statement_rows: normalized.statement_rows,
|
||||||
surface_rows: input.surface_rows,
|
surface_rows: normalized.surface_rows,
|
||||||
detail_rows: input.detail_rows,
|
detail_rows: normalized.detail_rows,
|
||||||
kpi_rows: input.kpi_rows,
|
kpi_rows: normalized.kpi_rows,
|
||||||
derived_metrics: input.derived_metrics,
|
derived_metrics: input.derived_metrics,
|
||||||
validation_result: input.validation_result,
|
validation_result: input.validation_result,
|
||||||
normalization_summary: input.normalization_summary,
|
normalization_summary: normalized.normalization_summary,
|
||||||
facts_count: input.facts_count,
|
facts_count: input.facts_count,
|
||||||
concepts_count: input.concepts_count,
|
concepts_count: input.concepts_count,
|
||||||
dimensions_count: input.dimensions_count,
|
dimensions_count: input.dimensions_count,
|
||||||
@@ -598,15 +1007,15 @@ export async function upsertFilingTaxonomySnapshot(input: UpsertFilingTaxonomySn
|
|||||||
parser_version: input.parser_version,
|
parser_version: input.parser_version,
|
||||||
taxonomy_regime: input.taxonomy_regime,
|
taxonomy_regime: input.taxonomy_regime,
|
||||||
fiscal_pack: input.fiscal_pack,
|
fiscal_pack: input.fiscal_pack,
|
||||||
periods: input.periods,
|
periods: normalized.periods,
|
||||||
faithful_rows: input.faithful_rows,
|
faithful_rows: normalized.faithful_rows,
|
||||||
statement_rows: input.statement_rows,
|
statement_rows: normalized.statement_rows,
|
||||||
surface_rows: input.surface_rows,
|
surface_rows: normalized.surface_rows,
|
||||||
detail_rows: input.detail_rows,
|
detail_rows: normalized.detail_rows,
|
||||||
kpi_rows: input.kpi_rows,
|
kpi_rows: normalized.kpi_rows,
|
||||||
derived_metrics: input.derived_metrics,
|
derived_metrics: input.derived_metrics,
|
||||||
validation_result: input.validation_result,
|
validation_result: input.validation_result,
|
||||||
normalization_summary: input.normalization_summary,
|
normalization_summary: normalized.normalization_summary,
|
||||||
facts_count: input.facts_count,
|
facts_count: input.facts_count,
|
||||||
concepts_count: input.concepts_count,
|
concepts_count: input.concepts_count,
|
||||||
dimensions_count: input.dimensions_count,
|
dimensions_count: input.dimensions_count,
|
||||||
@@ -906,3 +1315,8 @@ export async function listTaxonomyAssetsBySnapshotIds(snapshotIds: number[]) {
|
|||||||
|
|
||||||
return rows.map(toAssetRecord);
|
return rows.map(toAssetRecord);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const __filingTaxonomyInternals = {
|
||||||
|
normalizeFilingTaxonomySnapshotPayload,
|
||||||
|
toSnapshotRecord
|
||||||
|
};
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
} from '@/lib/server/repos/company-financial-bundles';
|
} from '@/lib/server/repos/company-financial-bundles';
|
||||||
import {
|
import {
|
||||||
getFilingTaxonomySnapshotByFilingId,
|
getFilingTaxonomySnapshotByFilingId,
|
||||||
|
normalizeFilingTaxonomySnapshotPayload,
|
||||||
upsertFilingTaxonomySnapshot
|
upsertFilingTaxonomySnapshot
|
||||||
} from '@/lib/server/repos/filing-taxonomy';
|
} from '@/lib/server/repos/filing-taxonomy';
|
||||||
import {
|
import {
|
||||||
@@ -726,6 +727,10 @@ async function processSyncFilings(task: Task) {
|
|||||||
filingUrl: filing.filing_url,
|
filingUrl: filing.filing_url,
|
||||||
primaryDocument: filing.primary_document ?? null
|
primaryDocument: filing.primary_document ?? null
|
||||||
});
|
});
|
||||||
|
const normalizedSnapshot = {
|
||||||
|
...snapshot,
|
||||||
|
...normalizeFilingTaxonomySnapshotPayload(snapshot)
|
||||||
|
};
|
||||||
|
|
||||||
await setProjectionStage(
|
await setProjectionStage(
|
||||||
task,
|
task,
|
||||||
@@ -752,8 +757,8 @@ async function processSyncFilings(task: Task) {
|
|||||||
stageContext('sync.persist_taxonomy')
|
stageContext('sync.persist_taxonomy')
|
||||||
);
|
);
|
||||||
|
|
||||||
await upsertFilingTaxonomySnapshot(snapshot);
|
await upsertFilingTaxonomySnapshot(normalizedSnapshot);
|
||||||
await updateFilingMetricsById(filing.id, snapshot.derived_metrics);
|
await updateFilingMetricsById(filing.id, normalizedSnapshot.derived_metrics);
|
||||||
await deleteCompanyFinancialBundlesForTicker(filing.ticker);
|
await deleteCompanyFinancialBundlesForTicker(filing.ticker);
|
||||||
taxonomySnapshotsHydrated += 1;
|
taxonomySnapshotsHydrated += 1;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -43,11 +43,11 @@ function createHydrationResult(): TaxonomyHydrationResult {
|
|||||||
facts: [],
|
facts: [],
|
||||||
metric_validations: [],
|
metric_validations: [],
|
||||||
normalization_summary: {
|
normalization_summary: {
|
||||||
surfaceRowCount: 0,
|
surface_row_count: 0,
|
||||||
detailRowCount: 0,
|
detail_row_count: 0,
|
||||||
kpiRowCount: 0,
|
kpi_row_count: 0,
|
||||||
unmappedRowCount: 0,
|
unmapped_row_count: 0,
|
||||||
materialUnmappedRowCount: 0,
|
material_unmapped_row_count: 0,
|
||||||
warnings: ['rust_warning']
|
warnings: ['rust_warning']
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
Filing,
|
Filing,
|
||||||
FinancialStatementKind,
|
FinancialStatementKind,
|
||||||
MetricValidationResult,
|
MetricValidationResult
|
||||||
NormalizationSummary,
|
|
||||||
StructuredKpiRow,
|
|
||||||
SurfaceDetailMap,
|
|
||||||
SurfaceFinancialRow,
|
|
||||||
TaxonomyStatementRow
|
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
import type {
|
import type {
|
||||||
FilingTaxonomyAssetType,
|
FilingTaxonomyAssetType,
|
||||||
@@ -117,6 +112,98 @@ export type TaxonomyMetricValidationCheck = {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TaxonomyHydrationPeriod = {
|
||||||
|
id: string;
|
||||||
|
filing_id: number;
|
||||||
|
accession_number: string;
|
||||||
|
filing_date: string;
|
||||||
|
period_start: string | null;
|
||||||
|
period_end: string | null;
|
||||||
|
filing_type: '10-K' | '10-Q';
|
||||||
|
period_label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TaxonomyHydrationStatementRow = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
concept_key: string;
|
||||||
|
qname: string;
|
||||||
|
namespace_uri: string;
|
||||||
|
local_name: string;
|
||||||
|
is_extension: boolean;
|
||||||
|
statement: FinancialStatementKind;
|
||||||
|
role_uri: string | null;
|
||||||
|
order: number;
|
||||||
|
depth: number;
|
||||||
|
parent_key: string | null;
|
||||||
|
values: Record<string, number | null>;
|
||||||
|
units: Record<string, string | null>;
|
||||||
|
has_dimensions: boolean;
|
||||||
|
source_fact_ids: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TaxonomyHydrationSurfaceRow = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
category: string;
|
||||||
|
template_section?: string;
|
||||||
|
order: number;
|
||||||
|
unit: 'currency' | 'count' | 'shares' | 'percent' | 'ratio';
|
||||||
|
values: Record<string, number | null>;
|
||||||
|
source_concepts: string[];
|
||||||
|
source_row_keys: string[];
|
||||||
|
source_fact_ids: number[];
|
||||||
|
formula_key: string | null;
|
||||||
|
has_dimensions: boolean;
|
||||||
|
resolved_source_row_keys: Record<string, string | null>;
|
||||||
|
statement?: 'income' | 'balance' | 'cash_flow';
|
||||||
|
detail_count?: number;
|
||||||
|
resolution_method?: 'direct' | 'surface_bridge' | 'formula_derived' | 'not_meaningful';
|
||||||
|
confidence?: 'high' | 'medium' | 'low';
|
||||||
|
warning_codes?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TaxonomyHydrationDetailRow = {
|
||||||
|
key: string;
|
||||||
|
parent_surface_key: string;
|
||||||
|
label: string;
|
||||||
|
concept_key: string;
|
||||||
|
qname: string;
|
||||||
|
namespace_uri: string;
|
||||||
|
local_name: string;
|
||||||
|
unit: string | null;
|
||||||
|
values: Record<string, number | null>;
|
||||||
|
source_fact_ids: number[];
|
||||||
|
is_extension: boolean;
|
||||||
|
dimensions_summary: string[];
|
||||||
|
residual_flag: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TaxonomyHydrationStructuredKpiRow = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
category: string;
|
||||||
|
unit: 'currency' | 'count' | 'shares' | 'percent' | 'ratio';
|
||||||
|
order: number;
|
||||||
|
segment: string | null;
|
||||||
|
axis: string | null;
|
||||||
|
member: string | null;
|
||||||
|
values: Record<string, number | null>;
|
||||||
|
source_concepts: string[];
|
||||||
|
source_fact_ids: number[];
|
||||||
|
provenance_type: 'taxonomy' | 'structured_note';
|
||||||
|
has_dimensions: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TaxonomyHydrationNormalizationSummary = {
|
||||||
|
surface_row_count: number;
|
||||||
|
detail_row_count: number;
|
||||||
|
kpi_row_count: number;
|
||||||
|
unmapped_row_count: number;
|
||||||
|
material_unmapped_row_count: number;
|
||||||
|
warnings: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export type TaxonomyHydrationInput = {
|
export type TaxonomyHydrationInput = {
|
||||||
filingId: number;
|
filingId: number;
|
||||||
ticker: string;
|
ticker: string;
|
||||||
@@ -140,12 +227,12 @@ export type TaxonomyHydrationResult = {
|
|||||||
parser_version: string;
|
parser_version: string;
|
||||||
taxonomy_regime: 'us-gaap' | 'ifrs-full' | 'unknown';
|
taxonomy_regime: 'us-gaap' | 'ifrs-full' | 'unknown';
|
||||||
fiscal_pack: string | null;
|
fiscal_pack: string | null;
|
||||||
periods: FilingTaxonomyPeriod[];
|
periods: TaxonomyHydrationPeriod[];
|
||||||
faithful_rows: Record<FinancialStatementKind, TaxonomyStatementRow[]>;
|
faithful_rows: Record<FinancialStatementKind, TaxonomyHydrationStatementRow[]>;
|
||||||
statement_rows: Record<FinancialStatementKind, TaxonomyStatementRow[]>;
|
statement_rows: Record<FinancialStatementKind, TaxonomyHydrationStatementRow[]>;
|
||||||
surface_rows: Record<FinancialStatementKind, SurfaceFinancialRow[]>;
|
surface_rows: Record<FinancialStatementKind, TaxonomyHydrationSurfaceRow[]>;
|
||||||
detail_rows: Record<FinancialStatementKind, SurfaceDetailMap>;
|
detail_rows: Record<FinancialStatementKind, Record<string, TaxonomyHydrationDetailRow[]>>;
|
||||||
kpi_rows: StructuredKpiRow[];
|
kpi_rows: TaxonomyHydrationStructuredKpiRow[];
|
||||||
contexts: Array<{
|
contexts: Array<{
|
||||||
context_id: string;
|
context_id: string;
|
||||||
entity_identifier: string | null;
|
entity_identifier: string | null;
|
||||||
@@ -191,5 +278,5 @@ export type TaxonomyHydrationResult = {
|
|||||||
source_file: string | null;
|
source_file: string | null;
|
||||||
}>;
|
}>;
|
||||||
metric_validations: TaxonomyMetricValidationCheck[];
|
metric_validations: TaxonomyMetricValidationCheck[];
|
||||||
normalization_summary: NormalizationSummary;
|
normalization_summary: TaxonomyHydrationNormalizationSummary;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { hydrateFilingTaxonomySnapshot } from '@/lib/server/taxonomy/engine';
|
|||||||
import { listFilingsRecords, updateFilingMetricsById } from '@/lib/server/repos/filings';
|
import { listFilingsRecords, updateFilingMetricsById } from '@/lib/server/repos/filings';
|
||||||
import {
|
import {
|
||||||
getFilingTaxonomySnapshotByFilingId,
|
getFilingTaxonomySnapshotByFilingId,
|
||||||
|
normalizeFilingTaxonomySnapshotPayload,
|
||||||
upsertFilingTaxonomySnapshot
|
upsertFilingTaxonomySnapshot
|
||||||
} from '@/lib/server/repos/filing-taxonomy';
|
} from '@/lib/server/repos/filing-taxonomy';
|
||||||
|
|
||||||
@@ -186,8 +187,12 @@ async function runBackfill(options: ScriptOptions): Promise<ScriptSummary> {
|
|||||||
summary.wouldWrite += 1;
|
summary.wouldWrite += 1;
|
||||||
|
|
||||||
if (options.apply) {
|
if (options.apply) {
|
||||||
await upsertFilingTaxonomySnapshot(snapshot);
|
const normalizedSnapshot = {
|
||||||
await updateFilingMetricsById(row.id, snapshot.derived_metrics);
|
...snapshot,
|
||||||
|
...normalizeFilingTaxonomySnapshotPayload(snapshot)
|
||||||
|
};
|
||||||
|
await upsertFilingTaxonomySnapshot(normalizedSnapshot);
|
||||||
|
await updateFilingMetricsById(row.id, normalizedSnapshot.derived_metrics);
|
||||||
summary.written += 1;
|
summary.written += 1;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -312,12 +312,14 @@ function relativeDiff(left: number | null, right: number | null) {
|
|||||||
return Math.abs(left - right) / baseline;
|
return Math.abs(left - right) / baseline;
|
||||||
}
|
}
|
||||||
|
|
||||||
function periodStart(period: ResultPeriod) {
|
function periodStart(period: ResultPeriod): string | null {
|
||||||
return period.periodStart ?? period.period_start ?? null;
|
const start = ('periodStart' in period ? period.periodStart : undefined) ?? period.period_start ?? null;
|
||||||
|
return typeof start === 'string' ? start : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function periodEnd(period: ResultPeriod) {
|
function periodEnd(period: ResultPeriod): string | null {
|
||||||
return period.periodEnd ?? period.period_end ?? null;
|
const end = ('periodEnd' in period ? period.periodEnd : undefined) ?? period.period_end ?? null;
|
||||||
|
return typeof end === 'string' ? end : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function chooseDurationPeriodId(result: TaxonomyHydrationResult) {
|
function chooseDurationPeriodId(result: TaxonomyHydrationResult) {
|
||||||
|
|||||||
Reference in New Issue
Block a user