- Add bundled financial modeling pipeline (ratios, KPI dimensions/notes, trend series, standardization) - Introduce company financial bundles storage (Drizzle migration + repo wiring) - Refactor financials page/API/query flow to use surfaceKind + cadence and new response shapes
160 lines
5.3 KiB
TypeScript
160 lines
5.3 KiB
TypeScript
import type {
|
|
FinancialStatementPeriod,
|
|
StructuredKpiRow,
|
|
TaxonomyFactRow
|
|
} from '@/lib/types';
|
|
import type { KpiDefinition } from '@/lib/server/financials/kpi-registry';
|
|
import { factMatchesPeriod } from '@/lib/server/financials/standardize';
|
|
|
|
function normalizeSegmentToken(value: string) {
|
|
return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
|
|
}
|
|
|
|
function humanizeMember(value: string) {
|
|
const source = value.split(':').pop() ?? value;
|
|
return source
|
|
.replace(/Member$/i, '')
|
|
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
.replace(/_/g, ' ')
|
|
.trim();
|
|
}
|
|
|
|
function factMatchesDefinition(fact: TaxonomyFactRow, definition: KpiDefinition) {
|
|
if (definition.preferredConceptNames && !definition.preferredConceptNames.includes(fact.localName)) {
|
|
return false;
|
|
}
|
|
|
|
if (!definition.preferredAxisIncludes || definition.preferredAxisIncludes.length === 0) {
|
|
return fact.dimensions.length > 0;
|
|
}
|
|
|
|
return fact.dimensions.some((dimension) => {
|
|
const axisMatch = definition.preferredAxisIncludes?.some((token) => dimension.axis.toLowerCase().includes(token.toLowerCase())) ?? false;
|
|
const memberMatch = definition.preferredMemberIncludes && definition.preferredMemberIncludes.length > 0
|
|
? definition.preferredMemberIncludes.some((token) => dimension.member.toLowerCase().includes(token.toLowerCase()))
|
|
: true;
|
|
return axisMatch && memberMatch;
|
|
});
|
|
}
|
|
|
|
function categoryForDefinition(definition: KpiDefinition, axis: string) {
|
|
if (definition.key === 'segment_revenue' && /geo|country|region|area/i.test(axis)) {
|
|
return 'geographic_mix';
|
|
}
|
|
|
|
return definition.category;
|
|
}
|
|
|
|
export function extractStructuredKpisFromDimensions(input: {
|
|
facts: TaxonomyFactRow[];
|
|
periods: FinancialStatementPeriod[];
|
|
definitions: KpiDefinition[];
|
|
}) {
|
|
const rowMap = new Map<string, StructuredKpiRow>();
|
|
const orderByKey = new Map<string, number>();
|
|
|
|
input.definitions.forEach((definition, index) => {
|
|
orderByKey.set(definition.key, (index + 1) * 10);
|
|
});
|
|
|
|
for (const definition of input.definitions) {
|
|
for (const fact of input.facts) {
|
|
if (fact.dimensions.length === 0 || !factMatchesDefinition(fact, definition)) {
|
|
continue;
|
|
}
|
|
|
|
const matchedPeriod = input.periods.find((period) => period.filingId === fact.filingId && factMatchesPeriod(fact, period));
|
|
if (!matchedPeriod) {
|
|
continue;
|
|
}
|
|
|
|
for (const dimension of fact.dimensions) {
|
|
const axis = dimension.axis;
|
|
const member = dimension.member;
|
|
const normalizedAxis = normalizeSegmentToken(axis);
|
|
const normalizedMember = normalizeSegmentToken(member);
|
|
const key = `${definition.key}__${normalizedAxis}__${normalizedMember}`;
|
|
const labelSuffix = humanizeMember(member);
|
|
const existing = rowMap.get(key);
|
|
|
|
if (existing) {
|
|
existing.values[matchedPeriod.id] = fact.value;
|
|
if (!existing.sourceConcepts.includes(fact.qname)) {
|
|
existing.sourceConcepts.push(fact.qname);
|
|
}
|
|
if (!existing.sourceFactIds.includes(fact.id)) {
|
|
existing.sourceFactIds.push(fact.id);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
rowMap.set(key, {
|
|
key,
|
|
label: `${definition.label} - ${labelSuffix}`,
|
|
category: categoryForDefinition(definition, axis),
|
|
unit: definition.unit,
|
|
order: orderByKey.get(definition.key) ?? 999,
|
|
segment: labelSuffix || null,
|
|
axis,
|
|
member,
|
|
values: { [matchedPeriod.id]: fact.value },
|
|
sourceConcepts: [fact.qname],
|
|
sourceFactIds: [fact.id],
|
|
provenanceType: 'taxonomy',
|
|
hasDimensions: true
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
const rows = [...rowMap.values()].sort((left, right) => {
|
|
if (left.order !== right.order) {
|
|
return left.order - right.order;
|
|
}
|
|
|
|
return left.label.localeCompare(right.label);
|
|
});
|
|
|
|
const marginRows = new Map<string, StructuredKpiRow>();
|
|
for (const row of rows.filter((entry) => entry.category === 'segment_profit')) {
|
|
const revenueKey = row.key.replace(/^segment_profit__/, 'segment_revenue__');
|
|
const revenueRow = rowMap.get(revenueKey);
|
|
if (!revenueRow) {
|
|
continue;
|
|
}
|
|
|
|
const values: Record<string, number | null> = {};
|
|
for (const period of input.periods) {
|
|
const revenue = revenueRow.values[period.id] ?? null;
|
|
const profit = row.values[period.id] ?? null;
|
|
values[period.id] = revenue === null || profit === null || revenue === 0
|
|
? null
|
|
: profit / revenue;
|
|
}
|
|
|
|
marginRows.set(row.key.replace(/^segment_profit__/, 'segment_margin__'), {
|
|
key: row.key.replace(/^segment_profit__/, 'segment_margin__'),
|
|
label: row.label.replace(/^Segment Profit/, 'Segment Margin'),
|
|
category: 'segment_margin',
|
|
unit: 'percent',
|
|
order: 25,
|
|
segment: row.segment,
|
|
axis: row.axis,
|
|
member: row.member,
|
|
values,
|
|
sourceConcepts: [...new Set([...row.sourceConcepts, ...revenueRow.sourceConcepts])],
|
|
sourceFactIds: [...new Set([...row.sourceFactIds, ...revenueRow.sourceFactIds])],
|
|
provenanceType: 'taxonomy',
|
|
hasDimensions: true
|
|
});
|
|
}
|
|
|
|
return [...rows, ...marginRows.values()].sort((left, right) => {
|
|
if (left.order !== right.order) {
|
|
return left.order - right.order;
|
|
}
|
|
|
|
return left.label.localeCompare(right.label);
|
|
});
|
|
}
|