Automate issuer overlay creation from ticker searches
This commit is contained in:
@@ -57,6 +57,10 @@ function createResponse(partial: Partial<CompanyFinancialStatementsResponse>): C
|
||||
kpiRowCount: 0,
|
||||
unmappedRowCount: 0,
|
||||
materialUnmappedRowCount: 0,
|
||||
residualPrimaryCount: 0,
|
||||
residualDisclosureCount: 0,
|
||||
unsupportedConceptCount: 0,
|
||||
issuerOverlayMatchCount: 0,
|
||||
warnings: []
|
||||
},
|
||||
dimensionBreakdown: null,
|
||||
|
||||
@@ -2,7 +2,7 @@ import type {
|
||||
CompanyFinancialStatementsResponse
|
||||
} from '@/lib/types';
|
||||
|
||||
export function mergeDetailMaps(
|
||||
function mergeDetailMaps(
|
||||
base: CompanyFinancialStatementsResponse['statementDetails'],
|
||||
next: CompanyFinancialStatementsResponse['statementDetails']
|
||||
) {
|
||||
|
||||
@@ -1,317 +1,598 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
buildStatementTree,
|
||||
resolveStatementSelection
|
||||
} from '@/lib/financials/statement-view-model';
|
||||
import type { DetailFinancialRow, SurfaceFinancialRow } from '@/lib/types';
|
||||
resolveStatementSelection,
|
||||
} from "@/lib/financials/statement-view-model";
|
||||
import type { DetailFinancialRow, SurfaceFinancialRow } from "@/lib/types";
|
||||
|
||||
function createSurfaceRow(input: Partial<SurfaceFinancialRow> & Pick<SurfaceFinancialRow, 'key' | 'label' | 'values'>): SurfaceFinancialRow {
|
||||
function createSurfaceRow(
|
||||
input: Partial<SurfaceFinancialRow> &
|
||||
Pick<SurfaceFinancialRow, "key" | "label" | "values">,
|
||||
): SurfaceFinancialRow {
|
||||
return {
|
||||
key: input.key,
|
||||
label: input.label,
|
||||
category: input.category ?? 'revenue',
|
||||
category: input.category ?? "revenue",
|
||||
order: input.order ?? 10,
|
||||
unit: input.unit ?? 'currency',
|
||||
unit: input.unit ?? "currency",
|
||||
values: input.values,
|
||||
sourceConcepts: input.sourceConcepts ?? [input.key],
|
||||
sourceRowKeys: input.sourceRowKeys ?? [input.key],
|
||||
sourceFactIds: input.sourceFactIds ?? [1],
|
||||
formulaKey: input.formulaKey ?? null,
|
||||
hasDimensions: input.hasDimensions ?? false,
|
||||
resolvedSourceRowKeys: input.resolvedSourceRowKeys ?? Object.fromEntries(Object.keys(input.values).map((periodId) => [periodId, input.key])),
|
||||
statement: input.statement ?? 'income',
|
||||
resolvedSourceRowKeys:
|
||||
input.resolvedSourceRowKeys ??
|
||||
Object.fromEntries(
|
||||
Object.keys(input.values).map((periodId) => [periodId, input.key]),
|
||||
),
|
||||
statement: input.statement ?? "income",
|
||||
detailCount: input.detailCount,
|
||||
resolutionMethod: input.resolutionMethod,
|
||||
confidence: input.confidence,
|
||||
warningCodes: input.warningCodes
|
||||
warningCodes: input.warningCodes,
|
||||
};
|
||||
}
|
||||
|
||||
function createDetailRow(input: Partial<DetailFinancialRow> & Pick<DetailFinancialRow, 'key' | 'label' | 'parentSurfaceKey' | 'values'>): DetailFinancialRow {
|
||||
function createDetailRow(
|
||||
input: Partial<DetailFinancialRow> &
|
||||
Pick<DetailFinancialRow, "key" | "label" | "parentSurfaceKey" | "values">,
|
||||
): DetailFinancialRow {
|
||||
return {
|
||||
key: input.key,
|
||||
parentSurfaceKey: input.parentSurfaceKey,
|
||||
label: input.label,
|
||||
conceptKey: input.conceptKey ?? input.key,
|
||||
qname: input.qname ?? `us-gaap:${input.key}`,
|
||||
namespaceUri: input.namespaceUri ?? 'http://fasb.org/us-gaap/2024',
|
||||
namespaceUri: input.namespaceUri ?? "http://fasb.org/us-gaap/2024",
|
||||
localName: input.localName ?? input.key,
|
||||
unit: input.unit ?? 'USD',
|
||||
unit: input.unit ?? "USD",
|
||||
values: input.values,
|
||||
sourceFactIds: input.sourceFactIds ?? [100],
|
||||
isExtension: input.isExtension ?? false,
|
||||
dimensionsSummary: input.dimensionsSummary ?? [],
|
||||
residualFlag: input.residualFlag ?? false
|
||||
residualFlag: input.residualFlag ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
describe('statement view model', () => {
|
||||
const categories = [{ key: 'opex', label: 'Operating Expenses', count: 4 }];
|
||||
describe("statement view model", () => {
|
||||
const categories = [{ key: "opex", label: "Operating Expenses", count: 4 }];
|
||||
|
||||
it('builds a root-only tree when there are no configured children or details', () => {
|
||||
it("builds a root-only tree when there are no configured children or details", () => {
|
||||
const model = buildStatementTree({
|
||||
surfaceKind: 'income_statement',
|
||||
surfaceKind: "income_statement",
|
||||
rows: [
|
||||
createSurfaceRow({ key: 'revenue', label: 'Revenue', category: 'revenue', values: { p1: 100 } })
|
||||
createSurfaceRow({
|
||||
key: "revenue",
|
||||
label: "Revenue",
|
||||
category: "revenue",
|
||||
values: { p1: 100 },
|
||||
}),
|
||||
],
|
||||
statementDetails: null,
|
||||
categories: [],
|
||||
searchQuery: '',
|
||||
expandedRowKeys: new Set()
|
||||
searchQuery: "",
|
||||
expandedRowKeys: new Set(),
|
||||
});
|
||||
|
||||
expect(model.sections).toHaveLength(1);
|
||||
expect(model.sections[0]?.nodes[0]).toMatchObject({
|
||||
kind: 'surface',
|
||||
row: { key: 'revenue' },
|
||||
expandable: false
|
||||
kind: "surface",
|
||||
row: { key: "revenue" },
|
||||
expandable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('nests the operating expense child surfaces under the parent row', () => {
|
||||
it("nests the operating expense child surfaces under the parent row", () => {
|
||||
const model = buildStatementTree({
|
||||
surfaceKind: 'income_statement',
|
||||
surfaceKind: "income_statement",
|
||||
rows: [
|
||||
createSurfaceRow({ key: 'operating_expenses', label: 'Operating Expenses', category: 'opex', order: 10, values: { p1: 40 } }),
|
||||
createSurfaceRow({ key: 'selling_general_and_administrative', label: 'SG&A', category: 'opex', order: 20, values: { p1: 20 } }),
|
||||
createSurfaceRow({ key: 'research_and_development', label: 'Research Expense', category: 'opex', order: 30, values: { p1: 12 } }),
|
||||
createSurfaceRow({ key: 'other_operating_expense', label: 'Other Expense', category: 'opex', order: 40, values: { p1: 8 } })
|
||||
createSurfaceRow({
|
||||
key: "operating_expenses",
|
||||
label: "Operating Expenses",
|
||||
category: "opex",
|
||||
order: 10,
|
||||
values: { p1: 40 },
|
||||
}),
|
||||
createSurfaceRow({
|
||||
key: "selling_general_and_administrative",
|
||||
label: "SG&A",
|
||||
category: "opex",
|
||||
order: 20,
|
||||
values: { p1: 20 },
|
||||
}),
|
||||
createSurfaceRow({
|
||||
key: "research_and_development",
|
||||
label: "Research Expense",
|
||||
category: "opex",
|
||||
order: 30,
|
||||
values: { p1: 12 },
|
||||
}),
|
||||
createSurfaceRow({
|
||||
key: "other_operating_expense",
|
||||
label: "Other Expense",
|
||||
category: "opex",
|
||||
order: 40,
|
||||
values: { p1: 8 },
|
||||
}),
|
||||
],
|
||||
statementDetails: null,
|
||||
categories,
|
||||
searchQuery: '',
|
||||
expandedRowKeys: new Set(['operating_expenses'])
|
||||
searchQuery: "",
|
||||
expandedRowKeys: new Set(["operating_expenses"]),
|
||||
});
|
||||
|
||||
const parent = model.sections[0]?.nodes[0];
|
||||
expect(parent?.kind).toBe('surface');
|
||||
const childKeys = parent?.kind === 'surface'
|
||||
? parent.children.map((node) => node.row.key)
|
||||
: [];
|
||||
expect(parent?.kind).toBe("surface");
|
||||
const childKeys =
|
||||
parent?.kind === "surface"
|
||||
? parent.children.map((node) => node.row.key)
|
||||
: [];
|
||||
expect(childKeys).toEqual([
|
||||
'selling_general_and_administrative',
|
||||
'research_and_development',
|
||||
'other_operating_expense'
|
||||
"selling_general_and_administrative",
|
||||
"research_and_development",
|
||||
"other_operating_expense",
|
||||
]);
|
||||
});
|
||||
|
||||
it('nests raw detail rows under the matching child surface row', () => {
|
||||
it("nests raw detail rows under the matching child surface row", () => {
|
||||
const model = buildStatementTree({
|
||||
surfaceKind: 'income_statement',
|
||||
surfaceKind: "income_statement",
|
||||
rows: [
|
||||
createSurfaceRow({ key: 'operating_expenses', label: 'Operating Expenses', category: 'opex', order: 10, values: { p1: 40 } }),
|
||||
createSurfaceRow({ key: 'selling_general_and_administrative', label: 'SG&A', category: 'opex', order: 20, values: { p1: 20 } })
|
||||
createSurfaceRow({
|
||||
key: "operating_expenses",
|
||||
label: "Operating Expenses",
|
||||
category: "opex",
|
||||
order: 10,
|
||||
values: { p1: 40 },
|
||||
}),
|
||||
createSurfaceRow({
|
||||
key: "selling_general_and_administrative",
|
||||
label: "SG&A",
|
||||
category: "opex",
|
||||
order: 20,
|
||||
values: { p1: 20 },
|
||||
}),
|
||||
],
|
||||
statementDetails: {
|
||||
selling_general_and_administrative: [
|
||||
createDetailRow({ key: 'corporate_sga', label: 'Corporate SG&A', parentSurfaceKey: 'selling_general_and_administrative', values: { p1: 20 } })
|
||||
]
|
||||
createDetailRow({
|
||||
key: "corporate_sga",
|
||||
label: "Corporate SG&A",
|
||||
parentSurfaceKey: "selling_general_and_administrative",
|
||||
values: { p1: 20 },
|
||||
}),
|
||||
],
|
||||
},
|
||||
categories,
|
||||
searchQuery: '',
|
||||
expandedRowKeys: new Set(['operating_expenses', 'selling_general_and_administrative'])
|
||||
searchQuery: "",
|
||||
expandedRowKeys: new Set([
|
||||
"operating_expenses",
|
||||
"selling_general_and_administrative",
|
||||
]),
|
||||
});
|
||||
|
||||
const child = model.sections[0]?.nodes[0];
|
||||
expect(child?.kind).toBe('surface');
|
||||
const sgaNode = child?.kind === 'surface' ? child.children[0] : null;
|
||||
expect(sgaNode?.kind).toBe('surface');
|
||||
const detailNode = sgaNode?.kind === 'surface' ? sgaNode.children[0] : null;
|
||||
expect(child?.kind).toBe("surface");
|
||||
const sgaNode = child?.kind === "surface" ? child.children[0] : null;
|
||||
expect(sgaNode?.kind).toBe("surface");
|
||||
const detailNode = sgaNode?.kind === "surface" ? sgaNode.children[0] : null;
|
||||
expect(detailNode).toMatchObject({
|
||||
kind: 'detail',
|
||||
row: { key: 'corporate_sga' }
|
||||
kind: "detail",
|
||||
row: { key: "corporate_sga" },
|
||||
});
|
||||
});
|
||||
|
||||
it('auto-expands the parent chain when search matches a child surface or detail row', () => {
|
||||
const rows = [
|
||||
createSurfaceRow({ key: 'operating_expenses', label: 'Operating Expenses', category: 'opex', order: 10, values: { p1: 40 } }),
|
||||
createSurfaceRow({ key: 'research_and_development', label: 'Research Expense', category: 'opex', order: 20, values: { p1: 12 } })
|
||||
];
|
||||
|
||||
const childSearch = buildStatementTree({
|
||||
surfaceKind: 'income_statement',
|
||||
rows,
|
||||
statementDetails: null,
|
||||
categories,
|
||||
searchQuery: 'research',
|
||||
expandedRowKeys: new Set()
|
||||
});
|
||||
|
||||
expect(childSearch.autoExpandedKeys.has('operating_expenses')).toBe(true);
|
||||
expect(childSearch.sections[0]?.nodes[0]?.kind === 'surface' && childSearch.sections[0]?.nodes[0].expanded).toBe(true);
|
||||
|
||||
const detailSearch = buildStatementTree({
|
||||
surfaceKind: 'income_statement',
|
||||
rows,
|
||||
statementDetails: {
|
||||
research_and_development: [
|
||||
createDetailRow({ key: 'ai_lab_expense', label: 'AI Lab Expense', parentSurfaceKey: 'research_and_development', values: { p1: 12 } })
|
||||
]
|
||||
},
|
||||
categories,
|
||||
searchQuery: 'ai lab',
|
||||
expandedRowKeys: new Set()
|
||||
});
|
||||
|
||||
const parent = detailSearch.sections[0]?.nodes[0];
|
||||
const child = parent?.kind === 'surface' ? parent.children[0] : null;
|
||||
expect(parent?.kind === 'surface' && parent.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("dedupes raw detail rows that are already covered by a child surface concept", () => {
|
||||
const rows = [
|
||||
createSurfaceRow({
|
||||
key: 'gross_profit',
|
||||
label: 'Gross Profit',
|
||||
category: 'profit',
|
||||
values: { p1: null },
|
||||
resolutionMethod: 'not_meaningful',
|
||||
warningCodes: ['gross_profit_not_meaningful_bank_pack']
|
||||
})
|
||||
key: "operating_expenses",
|
||||
label: "Operating Expenses",
|
||||
category: "opex",
|
||||
order: 10,
|
||||
values: { p1: 40 },
|
||||
}),
|
||||
createSurfaceRow({
|
||||
key: "research_and_development",
|
||||
label: "Research Expense",
|
||||
category: "opex",
|
||||
order: 20,
|
||||
values: { p1: 12 },
|
||||
sourceConcepts: ["us-gaap:ResearchAndDevelopmentExpense"],
|
||||
}),
|
||||
];
|
||||
const details = {
|
||||
gross_profit: [
|
||||
createDetailRow({ key: 'gp_unmapped', label: 'Gross Profit residual', parentSurfaceKey: 'gross_profit', values: { p1: null }, residualFlag: true })
|
||||
]
|
||||
const statementDetails = {
|
||||
operating_expenses: [
|
||||
createDetailRow({
|
||||
key: "rnd_raw",
|
||||
label: "Research And Development Expense",
|
||||
parentSurfaceKey: "operating_expenses",
|
||||
conceptKey: "research_and_development_expense",
|
||||
qname: "us-gaap:ResearchAndDevelopmentExpense",
|
||||
localName: "ResearchAndDevelopmentExpense",
|
||||
values: { p1: 12 },
|
||||
}),
|
||||
createDetailRow({
|
||||
key: "general_admin",
|
||||
label: "General And Administrative Expense",
|
||||
parentSurfaceKey: "operating_expenses",
|
||||
conceptKey: "general_and_administrative_expense",
|
||||
qname: "us-gaap:GeneralAndAdministrativeExpense",
|
||||
localName: "GeneralAndAdministrativeExpense",
|
||||
values: { p1: 7 },
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
const model = buildStatementTree({
|
||||
surfaceKind: 'income_statement',
|
||||
surfaceKind: "income_statement",
|
||||
rows,
|
||||
statementDetails: details,
|
||||
statementDetails,
|
||||
categories,
|
||||
searchQuery: "",
|
||||
expandedRowKeys: new Set(["operating_expenses"]),
|
||||
});
|
||||
|
||||
const parent = model.sections[0]?.nodes[0];
|
||||
expect(parent?.kind).toBe("surface");
|
||||
if (parent?.kind !== "surface") {
|
||||
throw new Error("expected surface node");
|
||||
}
|
||||
|
||||
expect(parent.directDetailCount).toBe(1);
|
||||
expect(parent.children.map((node) => node.row.key)).toEqual([
|
||||
"research_and_development",
|
||||
"general_admin",
|
||||
]);
|
||||
expect(parent.children.some((node) => node.row.key === "rnd_raw")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(model.totalNodeCount).toBe(3);
|
||||
});
|
||||
|
||||
it("returns the deduped detail rows for a selected surface", () => {
|
||||
const rows = [
|
||||
createSurfaceRow({
|
||||
key: "operating_expenses",
|
||||
label: "Operating Expenses",
|
||||
category: "opex",
|
||||
order: 10,
|
||||
values: { p1: 40 },
|
||||
}),
|
||||
createSurfaceRow({
|
||||
key: "research_and_development",
|
||||
label: "Research Expense",
|
||||
category: "opex",
|
||||
order: 20,
|
||||
values: { p1: 12 },
|
||||
sourceConcepts: ["us-gaap:ResearchAndDevelopmentExpense"],
|
||||
}),
|
||||
];
|
||||
const statementDetails = {
|
||||
operating_expenses: [
|
||||
createDetailRow({
|
||||
key: "rnd_raw",
|
||||
label: "Research And Development Expense",
|
||||
parentSurfaceKey: "operating_expenses",
|
||||
conceptKey: "research_and_development_expense",
|
||||
qname: "us-gaap:ResearchAndDevelopmentExpense",
|
||||
localName: "ResearchAndDevelopmentExpense",
|
||||
values: { p1: 12 },
|
||||
}),
|
||||
createDetailRow({
|
||||
key: "general_admin",
|
||||
label: "General And Administrative Expense",
|
||||
parentSurfaceKey: "operating_expenses",
|
||||
conceptKey: "general_and_administrative_expense",
|
||||
qname: "us-gaap:GeneralAndAdministrativeExpense",
|
||||
localName: "GeneralAndAdministrativeExpense",
|
||||
values: { p1: 7 },
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
const selection = resolveStatementSelection({
|
||||
surfaceKind: "income_statement",
|
||||
rows,
|
||||
statementDetails,
|
||||
selection: { kind: "surface", key: "operating_expenses" },
|
||||
});
|
||||
|
||||
expect(selection).toMatchObject({
|
||||
kind: "surface",
|
||||
detailRows: [{ key: "general_admin" }],
|
||||
});
|
||||
expect(
|
||||
selection?.kind === "surface" &&
|
||||
selection.detailRows.some((row) => row.key === "rnd_raw"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("auto-expands the parent chain when search matches a child surface or detail row", () => {
|
||||
const rows = [
|
||||
createSurfaceRow({
|
||||
key: "operating_expenses",
|
||||
label: "Operating Expenses",
|
||||
category: "opex",
|
||||
order: 10,
|
||||
values: { p1: 40 },
|
||||
}),
|
||||
createSurfaceRow({
|
||||
key: "research_and_development",
|
||||
label: "Research Expense",
|
||||
category: "opex",
|
||||
order: 20,
|
||||
values: { p1: 12 },
|
||||
}),
|
||||
];
|
||||
|
||||
const childSearch = buildStatementTree({
|
||||
surfaceKind: "income_statement",
|
||||
rows,
|
||||
statementDetails: null,
|
||||
categories,
|
||||
searchQuery: "research",
|
||||
expandedRowKeys: new Set(),
|
||||
});
|
||||
|
||||
expect(childSearch.autoExpandedKeys.has("operating_expenses")).toBe(true);
|
||||
expect(
|
||||
childSearch.sections[0]?.nodes[0]?.kind === "surface" &&
|
||||
childSearch.sections[0]?.nodes[0].expanded,
|
||||
).toBe(true);
|
||||
|
||||
const detailSearch = buildStatementTree({
|
||||
surfaceKind: "income_statement",
|
||||
rows,
|
||||
statementDetails: {
|
||||
research_and_development: [
|
||||
createDetailRow({
|
||||
key: "ai_lab_expense",
|
||||
label: "AI Lab Expense",
|
||||
parentSurfaceKey: "research_and_development",
|
||||
values: { p1: 12 },
|
||||
}),
|
||||
],
|
||||
},
|
||||
categories,
|
||||
searchQuery: "ai lab",
|
||||
expandedRowKeys: new Set(),
|
||||
});
|
||||
|
||||
const parent = detailSearch.sections[0]?.nodes[0];
|
||||
const child = parent?.kind === "surface" ? parent.children[0] : null;
|
||||
expect(parent?.kind === "surface" && parent.expanded).toBe(true);
|
||||
expect(child?.kind === "surface" && child.expanded).toBe(true);
|
||||
});
|
||||
|
||||
it("searches hidden duplicate concepts through the mapped child surface without reintroducing the raw detail row", () => {
|
||||
const rows = [
|
||||
createSurfaceRow({
|
||||
key: "operating_expenses",
|
||||
label: "Operating Expenses",
|
||||
category: "opex",
|
||||
order: 10,
|
||||
values: { p1: 40 },
|
||||
}),
|
||||
createSurfaceRow({
|
||||
key: "research_and_development",
|
||||
label: "Research Expense",
|
||||
category: "opex",
|
||||
order: 20,
|
||||
values: { p1: 12 },
|
||||
sourceConcepts: ["us-gaap:ResearchAndDevelopmentExpense"],
|
||||
}),
|
||||
];
|
||||
|
||||
const model = buildStatementTree({
|
||||
surfaceKind: "income_statement",
|
||||
rows,
|
||||
statementDetails: {
|
||||
operating_expenses: [
|
||||
createDetailRow({
|
||||
key: "rnd_raw",
|
||||
label: "Research And Development Expense",
|
||||
parentSurfaceKey: "operating_expenses",
|
||||
conceptKey: "research_and_development_expense",
|
||||
qname: "us-gaap:ResearchAndDevelopmentExpense",
|
||||
localName: "ResearchAndDevelopmentExpense",
|
||||
values: { p1: 12 },
|
||||
}),
|
||||
],
|
||||
},
|
||||
categories,
|
||||
searchQuery: "researchanddevelopmentexpense",
|
||||
expandedRowKeys: new Set(),
|
||||
});
|
||||
|
||||
const parent = model.sections[0]?.nodes[0];
|
||||
expect(parent?.kind === "surface" && parent.expanded).toBe(true);
|
||||
expect(
|
||||
parent?.kind === "surface" && parent.children.map((node) => node.row.key),
|
||||
).toEqual(["research_and_development"]);
|
||||
});
|
||||
|
||||
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: '',
|
||||
expandedRowKeys: new Set(['gross_profit'])
|
||||
searchQuery: "revenue",
|
||||
expandedRowKeys: new Set(),
|
||||
});
|
||||
|
||||
expect(model.sections[0]?.nodes[0]).toMatchObject({
|
||||
kind: 'surface',
|
||||
row: { key: 'gross_profit', resolutionMethod: 'not_meaningful' }
|
||||
});
|
||||
|
||||
const surfaceSelection = resolveStatementSelection({
|
||||
surfaceKind: 'income_statement',
|
||||
rows,
|
||||
statementDetails: details,
|
||||
selection: { kind: 'surface', key: 'gross_profit' }
|
||||
});
|
||||
expect(surfaceSelection?.kind).toBe('surface');
|
||||
|
||||
const detailSelection = resolveStatementSelection({
|
||||
surfaceKind: 'income_statement',
|
||||
rows,
|
||||
statementDetails: details,
|
||||
selection: { kind: 'detail', key: 'gp_unmapped', parentKey: 'gross_profit' }
|
||||
});
|
||||
expect(detailSelection).toMatchObject({
|
||||
kind: 'detail',
|
||||
row: { key: 'gp_unmapped', residualFlag: true }
|
||||
kind: "surface",
|
||||
row: { key: "revenue" },
|
||||
});
|
||||
});
|
||||
|
||||
it('renders unmapped detail rows in a dedicated residual section and counts them', () => {
|
||||
it("keeps not meaningful rows visible and resolves selections for surface and detail nodes", () => {
|
||||
const rows = [
|
||||
createSurfaceRow({
|
||||
key: "gross_profit",
|
||||
label: "Gross Profit",
|
||||
category: "profit",
|
||||
values: { p1: null },
|
||||
resolutionMethod: "not_meaningful",
|
||||
warningCodes: ["gross_profit_not_meaningful_bank_pack"],
|
||||
}),
|
||||
];
|
||||
const details = {
|
||||
gross_profit: [
|
||||
createDetailRow({
|
||||
key: "gp_unmapped",
|
||||
label: "Gross Profit residual",
|
||||
parentSurfaceKey: "gross_profit",
|
||||
values: { p1: null },
|
||||
residualFlag: true,
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
const model = buildStatementTree({
|
||||
surfaceKind: 'income_statement',
|
||||
surfaceKind: "income_statement",
|
||||
rows,
|
||||
statementDetails: details,
|
||||
categories: [],
|
||||
searchQuery: "",
|
||||
expandedRowKeys: new Set(["gross_profit"]),
|
||||
});
|
||||
|
||||
expect(model.sections[0]?.nodes[0]).toMatchObject({
|
||||
kind: "surface",
|
||||
row: { key: "gross_profit", resolutionMethod: "not_meaningful" },
|
||||
});
|
||||
|
||||
const surfaceSelection = resolveStatementSelection({
|
||||
surfaceKind: "income_statement",
|
||||
rows,
|
||||
statementDetails: details,
|
||||
selection: { kind: "surface", key: "gross_profit" },
|
||||
});
|
||||
expect(surfaceSelection?.kind).toBe("surface");
|
||||
|
||||
const detailSelection = resolveStatementSelection({
|
||||
surfaceKind: "income_statement",
|
||||
rows,
|
||||
statementDetails: details,
|
||||
selection: {
|
||||
kind: "detail",
|
||||
key: "gp_unmapped",
|
||||
parentKey: "gross_profit",
|
||||
},
|
||||
});
|
||||
expect(detailSelection).toMatchObject({
|
||||
kind: "detail",
|
||||
row: { key: "gp_unmapped", residualFlag: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("renders unmapped detail rows in a dedicated residual section and counts them", () => {
|
||||
const model = buildStatementTree({
|
||||
surfaceKind: "income_statement",
|
||||
rows: [
|
||||
createSurfaceRow({ key: 'revenue', label: 'Revenue', category: 'revenue', values: { p1: 100 } })
|
||||
createSurfaceRow({
|
||||
key: "revenue",
|
||||
label: "Revenue",
|
||||
category: "revenue",
|
||||
values: { p1: 100 },
|
||||
}),
|
||||
],
|
||||
statementDetails: {
|
||||
unmapped: [
|
||||
createDetailRow({
|
||||
key: 'unmapped_other_income',
|
||||
label: 'Other income residual',
|
||||
parentSurfaceKey: 'unmapped',
|
||||
key: "unmapped_other_income",
|
||||
label: "Other income residual",
|
||||
parentSurfaceKey: "unmapped",
|
||||
values: { p1: 5 },
|
||||
residualFlag: true
|
||||
})
|
||||
]
|
||||
residualFlag: true,
|
||||
}),
|
||||
],
|
||||
},
|
||||
categories: [],
|
||||
searchQuery: '',
|
||||
expandedRowKeys: new Set()
|
||||
searchQuery: "",
|
||||
expandedRowKeys: new Set(),
|
||||
});
|
||||
|
||||
expect(model.sections).toHaveLength(2);
|
||||
expect(model.sections[1]).toMatchObject({
|
||||
key: 'unmapped_residual',
|
||||
label: 'Unmapped / Residual'
|
||||
key: "unmapped_residual",
|
||||
label: "Unmapped / Residual",
|
||||
});
|
||||
expect(model.sections[1]?.nodes[0]).toMatchObject({
|
||||
kind: 'detail',
|
||||
row: { key: 'unmapped_other_income', parentSurfaceKey: 'unmapped' }
|
||||
kind: "detail",
|
||||
row: { key: "unmapped_other_income", parentSurfaceKey: "unmapped" },
|
||||
});
|
||||
expect(model.visibleNodeCount).toBe(2);
|
||||
expect(model.totalNodeCount).toBe(2);
|
||||
});
|
||||
|
||||
it('matches search and resolves selection for unmapped detail rows without a real parent surface', () => {
|
||||
it("matches search and resolves selection for unmapped detail rows without a real parent surface", () => {
|
||||
const rows = [
|
||||
createSurfaceRow({ key: 'revenue', label: 'Revenue', category: 'revenue', values: { p1: 100 } })
|
||||
createSurfaceRow({
|
||||
key: "revenue",
|
||||
label: "Revenue",
|
||||
category: "revenue",
|
||||
values: { p1: 100 },
|
||||
}),
|
||||
];
|
||||
const statementDetails = {
|
||||
unmapped: [
|
||||
createDetailRow({
|
||||
key: 'unmapped_fx_gain',
|
||||
label: 'FX gain residual',
|
||||
parentSurfaceKey: 'unmapped',
|
||||
key: "unmapped_fx_gain",
|
||||
label: "FX gain residual",
|
||||
parentSurfaceKey: "unmapped",
|
||||
values: { p1: 2 },
|
||||
residualFlag: true
|
||||
})
|
||||
]
|
||||
residualFlag: true,
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
const model = buildStatementTree({
|
||||
surfaceKind: 'income_statement',
|
||||
surfaceKind: "income_statement",
|
||||
rows,
|
||||
statementDetails,
|
||||
categories: [],
|
||||
searchQuery: 'fx gain',
|
||||
expandedRowKeys: new Set()
|
||||
searchQuery: "fx gain",
|
||||
expandedRowKeys: new Set(),
|
||||
});
|
||||
|
||||
expect(model.sections).toHaveLength(1);
|
||||
expect(model.sections[0]).toMatchObject({
|
||||
key: 'unmapped_residual',
|
||||
label: 'Unmapped / Residual'
|
||||
key: "unmapped_residual",
|
||||
label: "Unmapped / Residual",
|
||||
});
|
||||
expect(model.visibleNodeCount).toBe(1);
|
||||
expect(model.totalNodeCount).toBe(2);
|
||||
|
||||
const selection = resolveStatementSelection({
|
||||
surfaceKind: 'income_statement',
|
||||
surfaceKind: "income_statement",
|
||||
rows,
|
||||
statementDetails,
|
||||
selection: { kind: 'detail', key: 'unmapped_fx_gain', parentKey: 'unmapped' }
|
||||
selection: {
|
||||
kind: "detail",
|
||||
key: "unmapped_fx_gain",
|
||||
parentKey: "unmapped",
|
||||
},
|
||||
});
|
||||
|
||||
expect(selection).toMatchObject({
|
||||
kind: 'detail',
|
||||
row: { key: 'unmapped_fx_gain', parentSurfaceKey: 'unmapped' },
|
||||
parentSurfaceRow: null
|
||||
kind: "detail",
|
||||
row: { key: "unmapped_fx_gain", parentSurfaceKey: "unmapped" },
|
||||
parentSurfaceRow: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,11 @@ const SURFACE_CHILDREN: Partial<
|
||||
Record<
|
||||
Extract<
|
||||
FinancialSurfaceKind,
|
||||
"income_statement" | "balance_sheet" | "cash_flow_statement"
|
||||
| "income_statement"
|
||||
| "balance_sheet"
|
||||
| "cash_flow_statement"
|
||||
| "equity_statement"
|
||||
| "disclosures"
|
||||
>,
|
||||
Record<string, string[]>
|
||||
>
|
||||
@@ -110,6 +114,8 @@ const SURFACE_CHILDREN: Partial<
|
||||
"other_financing_activities",
|
||||
],
|
||||
},
|
||||
equity_statement: {},
|
||||
disclosures: {},
|
||||
};
|
||||
|
||||
export type StatementInspectorSelection = {
|
||||
@@ -151,7 +157,7 @@ export type StatementTreeSection = {
|
||||
nodes: StatementTreeNode[];
|
||||
};
|
||||
|
||||
export type StatementTreeModel = {
|
||||
type StatementTreeModel = {
|
||||
sections: StatementTreeSection[];
|
||||
autoExpandedKeys: Set<string>;
|
||||
visibleNodeCount: number;
|
||||
@@ -184,7 +190,11 @@ const UNMAPPED_SECTION_LABEL = "Unmapped / Residual";
|
||||
function surfaceConfigForKind(
|
||||
surfaceKind: Extract<
|
||||
FinancialSurfaceKind,
|
||||
"income_statement" | "balance_sheet" | "cash_flow_statement"
|
||||
| "income_statement"
|
||||
| "balance_sheet"
|
||||
| "cash_flow_statement"
|
||||
| "equity_statement"
|
||||
| "disclosures"
|
||||
>,
|
||||
) {
|
||||
return SURFACE_CHILDREN[surfaceKind] ?? {};
|
||||
@@ -198,6 +208,101 @@ function normalize(value: string) {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeConceptIdentity(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = value.trim().toLowerCase();
|
||||
if (trimmed.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const withoutNamespace = trimmed.includes(":")
|
||||
? (trimmed.split(":").pop() ?? trimmed)
|
||||
: trimmed;
|
||||
const normalized = withoutNamespace.replace(/[\s_-]+/g, "");
|
||||
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function surfaceConceptIdentities(row: SurfaceFinancialRow) {
|
||||
const identities = new Set<string>();
|
||||
|
||||
const addIdentity = (value: string | null | undefined) => {
|
||||
const normalized = normalizeConceptIdentity(value);
|
||||
if (normalized) {
|
||||
identities.add(normalized);
|
||||
}
|
||||
};
|
||||
|
||||
addIdentity(row.key);
|
||||
|
||||
for (const sourceConcept of row.sourceConcepts ?? []) {
|
||||
addIdentity(sourceConcept);
|
||||
}
|
||||
|
||||
for (const sourceRowKey of row.sourceRowKeys ?? []) {
|
||||
addIdentity(sourceRowKey);
|
||||
}
|
||||
|
||||
for (const resolvedSourceRowKey of Object.values(
|
||||
row.resolvedSourceRowKeys ?? {},
|
||||
)) {
|
||||
addIdentity(resolvedSourceRowKey);
|
||||
}
|
||||
|
||||
return identities;
|
||||
}
|
||||
|
||||
function detailConceptIdentities(row: DetailFinancialRow) {
|
||||
const identities = new Set<string>();
|
||||
|
||||
const addIdentity = (value: string | null | undefined) => {
|
||||
const normalized = normalizeConceptIdentity(value);
|
||||
if (normalized) {
|
||||
identities.add(normalized);
|
||||
}
|
||||
};
|
||||
|
||||
addIdentity(row.key);
|
||||
addIdentity(row.conceptKey);
|
||||
addIdentity(row.qname);
|
||||
addIdentity(row.localName);
|
||||
|
||||
return identities;
|
||||
}
|
||||
|
||||
function dedupeDetailRowsAgainstChildSurfaces(
|
||||
detailRows: DetailFinancialRow[],
|
||||
childSurfaceRows: SurfaceFinancialRow[],
|
||||
) {
|
||||
if (detailRows.length === 0 || childSurfaceRows.length === 0) {
|
||||
return detailRows;
|
||||
}
|
||||
|
||||
const childConceptIdentities = new Set<string>();
|
||||
for (const childSurfaceRow of childSurfaceRows) {
|
||||
for (const identity of surfaceConceptIdentities(childSurfaceRow)) {
|
||||
childConceptIdentities.add(identity);
|
||||
}
|
||||
}
|
||||
|
||||
if (childConceptIdentities.size === 0) {
|
||||
return detailRows;
|
||||
}
|
||||
|
||||
return detailRows.filter((detailRow) => {
|
||||
for (const identity of detailConceptIdentities(detailRow)) {
|
||||
if (childConceptIdentities.has(identity)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function searchTextForSurface(row: SurfaceFinancialRow) {
|
||||
return [
|
||||
row.label,
|
||||
@@ -283,7 +388,11 @@ function countNodes(nodes: StatementTreeNode[]) {
|
||||
export function buildStatementTree(input: {
|
||||
surfaceKind: Extract<
|
||||
FinancialSurfaceKind,
|
||||
"income_statement" | "balance_sheet" | "cash_flow_statement"
|
||||
| "income_statement"
|
||||
| "balance_sheet"
|
||||
| "cash_flow_statement"
|
||||
| "equity_statement"
|
||||
| "disclosures"
|
||||
>;
|
||||
rows: SurfaceFinancialRow[];
|
||||
statementDetails: SurfaceDetailMap | null;
|
||||
@@ -316,8 +425,9 @@ export function buildStatementTree(input: {
|
||||
Boolean(candidate),
|
||||
)
|
||||
.sort(sortSurfaceRows);
|
||||
const detailRows = [...(input.statementDetails?.[row.key] ?? [])].sort(
|
||||
sortDetailRows,
|
||||
const detailRows = dedupeDetailRowsAgainstChildSurfaces(
|
||||
[...(input.statementDetails?.[row.key] ?? [])].sort(sortDetailRows),
|
||||
childSurfaceRows,
|
||||
);
|
||||
const childSurfaceNodes = childSurfaceRows
|
||||
.map((childRow) => buildSurfaceNode(childRow, level + 1))
|
||||
@@ -389,6 +499,22 @@ export function buildStatementTree(input: {
|
||||
.map((row) => buildSurfaceNode(row, 0))
|
||||
.filter((node): node is StatementTreeSurfaceNode => Boolean(node));
|
||||
|
||||
const totalVisibleDetailCount = input.rows.reduce((sum, row) => {
|
||||
const childSurfaceRows = (config[row.key] ?? [])
|
||||
.map((key) => rowByKey.get(key))
|
||||
.filter((candidate): candidate is SurfaceFinancialRow =>
|
||||
Boolean(candidate),
|
||||
);
|
||||
const detailRows = dedupeDetailRowsAgainstChildSurfaces(
|
||||
[...(input.statementDetails?.[row.key] ?? [])].sort(sortDetailRows),
|
||||
childSurfaceRows,
|
||||
);
|
||||
|
||||
return sum + detailRows.length;
|
||||
}, 0);
|
||||
const unmappedDetailCount =
|
||||
input.statementDetails?.[UNMAPPED_DETAIL_GROUP_KEY]?.length ?? 0;
|
||||
|
||||
if (input.categories.length === 0) {
|
||||
const sections: StatementTreeSection[] =
|
||||
rootNodes.length > 0
|
||||
@@ -415,11 +541,7 @@ export function buildStatementTree(input: {
|
||||
0,
|
||||
),
|
||||
totalNodeCount:
|
||||
input.rows.length +
|
||||
Object.values(input.statementDetails ?? {}).reduce(
|
||||
(sum, rows) => sum + rows.length,
|
||||
0,
|
||||
),
|
||||
input.rows.length + totalVisibleDetailCount + unmappedDetailCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -472,18 +594,18 @@ export function buildStatementTree(input: {
|
||||
0,
|
||||
),
|
||||
totalNodeCount:
|
||||
input.rows.length +
|
||||
Object.values(input.statementDetails ?? {}).reduce(
|
||||
(sum, rows) => sum + rows.length,
|
||||
0,
|
||||
),
|
||||
input.rows.length + totalVisibleDetailCount + unmappedDetailCount,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveStatementSelection(input: {
|
||||
surfaceKind: Extract<
|
||||
FinancialSurfaceKind,
|
||||
"income_statement" | "balance_sheet" | "cash_flow_statement"
|
||||
| "income_statement"
|
||||
| "balance_sheet"
|
||||
| "cash_flow_statement"
|
||||
| "equity_statement"
|
||||
| "disclosures"
|
||||
>;
|
||||
rows: SurfaceFinancialRow[];
|
||||
statementDetails: SurfaceDetailMap | null;
|
||||
@@ -510,14 +632,16 @@ export function resolveStatementSelection(input: {
|
||||
Boolean(candidate),
|
||||
)
|
||||
.sort(sortSurfaceRows);
|
||||
const detailRows = dedupeDetailRowsAgainstChildSurfaces(
|
||||
[...(input.statementDetails?.[row.key] ?? [])].sort(sortDetailRows),
|
||||
childSurfaceRows,
|
||||
);
|
||||
|
||||
return {
|
||||
kind: "surface",
|
||||
row,
|
||||
childSurfaceRows,
|
||||
detailRows: [...(input.statementDetails?.[row.key] ?? [])].sort(
|
||||
sortDetailRows,
|
||||
),
|
||||
detailRows,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user