From 232108642b2fd190281b3dedf22b7e2504d23808 Mon Sep 17 00:00:00 2001 From: "Joseph T. French" Date: Thu, 8 Oct 2026 23:29:42 -0500 Subject: [PATCH 1/3] feat(ledger): information block reads show one entity's books Block reads take entity_id (default the group parent) on GraphQL informationBlock/informationBlocks and the two MCP read tools. A block shared by the group (statements, metrics, disclosures) shows that entity's fact sets; a schedule, reconciliation or forecast always shows its owner's, and the list leaves out other entities' own blocks. The envelope names the entity it read. A forecast's history, and compute-forecast's choice of actual structure, follow the forecast's entity. --- .../graphql/resolvers/information_block.py | 70 ++-- .../graphql/types/information_block.py | 8 + .../mcp/tools/information_block_tools.py | 20 + robosystems/models/api/information_block.py | 11 + .../information_block/disclosure.py | 7 +- .../operations/information_block/envelope.py | 50 ++- .../operations/information_block/forecast.py | 13 +- .../information_block/forecast_compute.py | 8 +- .../information_block/forecast_history.py | 26 +- .../operations/information_block/metric.py | 13 +- .../operations/information_block/reads.py | 104 +++++- .../information_block/reconciliation.py | 12 +- .../information_block/rollforward.py | 2 + .../operations/information_block/schedule.py | 2 + .../operations/information_block/statement.py | 6 +- .../information_block/text_block.py | 5 +- .../mcp/tools/test_information_block_tools.py | 2 + .../information_block/test_entity_scope_db.py | 343 ++++++++++++++++++ .../test_forecast_compute.py | 2 +- .../test_forecast_handlers.py | 1 + .../test_forecast_history.py | 8 +- .../information_block/test_reads.py | 44 ++- .../test_text_block_handlers.py | 2 +- 23 files changed, 676 insertions(+), 83 deletions(-) create mode 100644 tests/operations/information_block/test_entity_scope_db.py diff --git a/robosystems/graphql/resolvers/information_block.py b/robosystems/graphql/resolvers/information_block.py index 26cc81c8d..1a7eb8f97 100644 --- a/robosystems/graphql/resolvers/information_block.py +++ b/robosystems/graphql/resolvers/information_block.py @@ -22,6 +22,7 @@ get_information_block, list_information_blocks, ) +from robosystems.operations.roboledger.entity_scope import EntityNotInGraphError @strawberry.type @@ -46,6 +47,7 @@ def information_block( series: bool | None = None, series_history: int | None = None, series_forecast: int | None = None, + entity_id: str | None = None, ) -> InformationBlock | None: """Fetch a single Information Block envelope by id. @@ -65,6 +67,12 @@ def information_block( forecast columns; omitted = unbounded. Pass the visible window so the envelope scales with the screen, not the ledger's age. + `entityId` picks whose books a block shared by the group reads + (statements, metrics, disclosures); omitted = the scenario's entity, + else the group parent. A schedule, reconciliation or forecast is one + entity's own and always reads its owner's. `entityId` on the envelope + names the entity it read. + Args: id: The block's structure id. scenario_id: A forecast block's structure id. Omit for actuals. @@ -73,17 +81,25 @@ def information_block( series_history: Cap the series at the last N actual columns. Omit for unbounded. series_forecast: Cap the series at the first N forecast columns. Omit for unbounded. + entity_id: The entity whose books a shared block reads. Omit for the group + parent. """ - with _open_session(info) as session: - envelope = get_information_block( - session, - str(id), - scenario_id=scenario_id, - series=bool(series), - series_history=series_history, - series_forecast=series_forecast, - ) - return InformationBlock.from_pydantic(envelope) if envelope else None + graph_id = require_graph_id(info) + try: + with _open_session(info) as session: + envelope = get_information_block( + session, + str(id), + scenario_id=scenario_id, + series=bool(series), + series_history=series_history, + series_forecast=series_forecast, + entity_id=entity_id, + library_sentinel=(graph_id == LIBRARY_GRAPH_ID), + ) + except EntityNotInGraphError as exc: + raise strawberry.exceptions.StrawberryGraphQLError(str(exc)) from exc + return InformationBlock.from_pydantic(envelope) if envelope else None @strawberry.field def information_blocks( @@ -94,6 +110,7 @@ def information_blocks( limit: int | None = None, offset: int | None = None, scenario_id: str | None = None, + entity_id: str | None = None, ) -> list[InformationBlock]: """List Information Blocks with optional block_type + category filters. @@ -103,26 +120,37 @@ def information_blocks( `scenarioId` threads into each envelope's FactSet binding (the Structure list itself is scenario-independent). + `entityId` (omitted = the scenario's entity, else the group parent) + lists the blocks shared by the group plus that entity's own schedules, + reconciliations and forecasts, never another entity's; shared blocks + read its books. + Args: block_type: Filter to one registered block type, e.g. `schedule`. category: Filter on the registry entry's category label, e.g. `Close` or `Reporting`. scenario_id: A forecast block's structure id, threaded into each envelope's FactSet binding. + entity_id: The entity whose blocks and books to list. Omit for the group + parent. """ limit, offset = _resolve_pagination(limit, offset, default_limit=50) graph_id = require_graph_id(info) - with _open_session(info) as session: - rows = list_information_blocks( - session, - block_type=block_type, - category=category, - limit=limit, - offset=offset, - library_sentinel=(graph_id == LIBRARY_GRAPH_ID), - scenario_id=scenario_id, - ) - return [InformationBlock.from_pydantic(r) for r in rows] + try: + with _open_session(info) as session: + rows = list_information_blocks( + session, + block_type=block_type, + category=category, + limit=limit, + offset=offset, + library_sentinel=(graph_id == LIBRARY_GRAPH_ID), + scenario_id=scenario_id, + entity_id=entity_id, + ) + except EntityNotInGraphError as exc: + raise strawberry.exceptions.StrawberryGraphQLError(str(exc)) from exc + return [InformationBlock.from_pydantic(r) for r in rows] __all__ = [ diff --git a/robosystems/graphql/types/information_block.py b/robosystems/graphql/types/information_block.py index f1f7d017d..0341c597e 100644 --- a/robosystems/graphql/types/information_block.py +++ b/robosystems/graphql/types/information_block.py @@ -232,6 +232,13 @@ class InformationBlock: taxonomy_id: str | None taxonomy_name: str | None disclosure_id: str | None + entity_id: str | None = strawberry.field( + description=( + "The entity whose books the envelope reads: a schedule, reconciliation " + "or forecast reads its owner's; a shared block (statements, metrics) " + "the entity asked for, else the group parent." + ) + ) information_model: InformationModel artifact: Artifact @@ -259,6 +266,7 @@ def from_pydantic(cls, envelope: PydanticInformationBlock) -> InformationBlock: taxonomy_id=envelope.taxonomy_id, taxonomy_name=envelope.taxonomy_name, disclosure_id=envelope.disclosure_id, + entity_id=envelope.entity_id, information_model=InformationModel.from_pydantic(envelope.information_model), artifact=Artifact.from_pydantic(envelope.artifact), elements=[InformationBlockElement.from_pydantic(e) for e in envelope.elements], diff --git a/robosystems/middleware/mcp/tools/information_block_tools.py b/robosystems/middleware/mcp/tools/information_block_tools.py index 38c301448..aa94d5249 100644 --- a/robosystems/middleware/mcp/tools/information_block_tools.py +++ b/robosystems/middleware/mcp/tools/information_block_tools.py @@ -16,8 +16,10 @@ from robosystems.operations.information_block import ( list_information_blocks as ops_list_information_blocks, ) +from robosystems.operations.roboledger.entity_scope import EntityNotInGraphError from ._errors import database_failure +from .constants import ENTITY_ID_ARGUMENT # ──────────────────────────────────────────────────────────────────────────── # get-information-block @@ -54,6 +56,10 @@ def get_tool_definition(self) -> dict[str, Any]: seam-adjacent columns — the last N actual columns and the first N forecast columns. Omitted = unbounded. Prefer a window on deep-history tenants: an unbounded series envelope grows with the ledger's age +- entity_id (optional): Whose books a block shared by the group reads + (statements, metrics, disclosures). Omitted = the scenario's entity, else + the group parent. A schedule, reconciliation or forecast always reads its + own entity's; the envelope's entity_id names the entity it read **RETURNS:** A typed envelope with: @@ -109,6 +115,7 @@ def get_tool_definition(self) -> dict[str, Any]: "(nearest the seam). Omitted = the full horizon." ), }, + "entity_id": ENTITY_ID_ARGUMENT, }, "required": ["id"], }, @@ -134,6 +141,8 @@ def _execute_sync(self, arguments: dict[str, Any]) -> Any: series=series, series_history=int(series_history) if series_history is not None else None, series_forecast=int(series_forecast) if series_forecast is not None else None, + entity_id=arguments.get("entity_id"), + library_sentinel=(graph_id == LIBRARY_GRAPH_ID), ) if envelope is None: return { @@ -141,6 +150,8 @@ def _execute_sync(self, arguments: dict[str, Any]) -> Any: "message": f"Information Block not found: {block_id}", } return envelope.model_dump(mode="json") + except EntityNotInGraphError as exc: + return {"error": "entity_not_found", "message": str(exc)} except SQLAlchemyError as exc: return database_failure("get-information-block", exc) except Exception as exc: @@ -177,6 +188,10 @@ def get_tool_definition(self) -> dict[str, Any]: - scenario_id (optional): A forecast block's structure id — each envelope binds that scenario's FactSet slice instead of actuals (list blocks with block_type='forecast' to discover scenarios) +- entity_id (optional): List the blocks shared by the group plus this + entity's own schedules, reconciliations and forecasts, never another + entity's; shared blocks read its books. Omitted = the scenario's entity, + else the group parent - include_atoms (optional, default false): When false, returns a lean summary per block (id, type, name, display_name, category, taxonomy_id, taxonomy_name, disclosure_id, element_count, fact_count, rule_count). @@ -234,6 +249,7 @@ def get_tool_definition(self) -> dict[str, Any]: "scenario's FactSet slice instead of actuals." ), }, + "entity_id": ENTITY_ID_ARGUMENT, }, "required": [], }, @@ -274,6 +290,7 @@ def _execute_sync(self, arguments: dict[str, Any]) -> Any: offset=offset, library_sentinel=(graph_id == LIBRARY_GRAPH_ID), scenario_id=scenario_id, + entity_id=arguments.get("entity_id"), ) if include_atoms: blocks = [e.model_dump(mode="json") for e in envelopes] @@ -286,6 +303,8 @@ def _execute_sync(self, arguments: dict[str, Any]) -> Any: "block_count": len(blocks), "blocks": blocks, } + except EntityNotInGraphError as exc: + return {"error": "entity_not_found", "message": str(exc)} except ValueError as exc: # Unknown block_type. return {"error": "invalid_arguments", "message": str(exc)} @@ -307,6 +326,7 @@ def _summarize_information_block(envelope) -> dict[str, Any]: "taxonomy_id": envelope.taxonomy_id, "taxonomy_name": envelope.taxonomy_name, "disclosure_id": envelope.disclosure_id, + "entity_id": envelope.entity_id, "element_count": len(envelope.elements or []), "connection_count": len(envelope.connections or []), "fact_count": len(envelope.facts or []), diff --git a/robosystems/models/api/information_block.py b/robosystems/models/api/information_block.py index 8c1a0f0db..a74c13eb2 100644 --- a/robosystems/models/api/information_block.py +++ b/robosystems/models/api/information_block.py @@ -1141,6 +1141,17 @@ class InformationBlockEnvelope(BaseModel): ), ) + entity_id: str | None = Field( + None, + description=( + "The entity whose books the envelope reads. A schedule, " + "reconciliation or forecast is one entity's own and always reads its " + "owner's; a block shared by the group (statements, metrics) reads " + "the entity asked for, else the group parent. Null on the library " + "and on a graph with no entity yet." + ), + ) + information_model: InformationModelResponse artifact: ArtifactResponse diff --git a/robosystems/operations/information_block/disclosure.py b/robosystems/operations/information_block/disclosure.py index 67e1326cf..af549be8f 100644 --- a/robosystems/operations/information_block/disclosure.py +++ b/robosystems/operations/information_block/disclosure.py @@ -38,6 +38,7 @@ def build_envelope( series: bool = False, series_history: int | None = None, series_forecast: int | None = None, + entity_id: str | None = None, ) -> InformationBlockEnvelope | None: """Pack the envelope for a disclosure-note structure. @@ -48,8 +49,10 @@ def build_envelope( if structure is None or structure.block_type != DISCLOSURE_BLOCK_TYPE: return None if (structure.concept_arrangement or "") in TEXT_BLOCK_CAPS: - return build_text_block_envelope(session, structure_id, fact_set_id) - envelope = _build_disclosure_envelope(session, structure_id, fact_set_id) + return build_text_block_envelope(session, structure_id, fact_set_id, entity_id) + envelope = _build_disclosure_envelope( + session, structure_id, fact_set_id, entity_id=entity_id + ) if envelope is None or not envelope.connections: return None return envelope diff --git a/robosystems/operations/information_block/envelope.py b/robosystems/operations/information_block/envelope.py index 2ab8c4176..febf8ea8c 100644 --- a/robosystems/operations/information_block/envelope.py +++ b/robosystems/operations/information_block/envelope.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from datetime import date -from sqlalchemy import or_, select +from sqlalchemy import and_, or_, select from sqlalchemy.orm import Session from robosystems.models.api.information_block import ( @@ -192,19 +192,25 @@ def verification_result_to_lite(row: VerificationResult) -> VerificationResultLi def load_verification_results_for_structure( - session: Session, structure_id: str, scenario_id: str | None = None + session: Session, + structure_id: str, + scenario_id: str | None = None, + entity_id: str | None = None, ) -> list[VerificationResultLite]: """Verification results for a Structure, newest first, scoped to a slice. compute-forecast verifies scenario months against the statement structures, so results pinned to a set are filtered by that set's - scenario (``None`` = actuals). Unpinned results always appear. + scenario (``None`` = actuals) and, given ``entity_id``, by its entity. + Unpinned results always appear. """ scenario_predicate = ( FactSet.scenario_id.is_(None) if scenario_id is None else FactSet.scenario_id == scenario_id ) + if entity_id is not None: + scenario_predicate = and_(scenario_predicate, FactSet.entity_id == entity_id) rows = ( session.execute( select(VerificationResult) @@ -275,15 +281,24 @@ def build_verification_summary( ) +def _entity_filter(entity_id: str | None) -> tuple: + """``FactSet.entity_id == entity_id`` when one is named, else nothing.""" + return () if entity_id is None else (FactSet.entity_id == entity_id,) + + def load_latest_fact_set_for_structure( - session: Session, structure_id: str, scenario_id: str | None = None + session: Session, + structure_id: str, + scenario_id: str | None = None, + entity_id: str | None = None, ) -> FactSetLite | None: """Fetch the latest FactSet (by ``period_end``) for a Structure, if any. ``scenario_id=None`` pins actuals; this is load-bearing, since scenario - sets sit at future period_ends and would otherwise win. At equal - ``period_end``, canonical sets (``report_id IS NULL``) beat publication - snapshots, then newest first. + sets sit at future period_ends and would otherwise win. ``entity_id`` + keeps one entity's sets: statement structures are shared by the group. + At equal ``period_end``, canonical sets (``report_id IS NULL``) beat + publication snapshots, then newest first. """ row = session.execute( select(FactSet) @@ -292,6 +307,7 @@ def load_latest_fact_set_for_structure( FactSet.scenario_id.is_(None) if scenario_id is None else FactSet.scenario_id == scenario_id, + *_entity_filter(entity_id), ) .order_by( FactSet.period_end.desc(), @@ -304,13 +320,16 @@ def load_latest_fact_set_for_structure( def load_statement_fact_set_series( - session: Session, structure_id: str, scenario_id: str | None = None + session: Session, + structure_id: str, + scenario_id: str | None = None, + entity_id: str | None = None, ) -> list[FactSetLite]: """The report-set series for a statement structure, one per period_end, ascending. ``scenario_id=None`` loads actuals; a scenario id adds that scenario's - sets. Sets whose window strictly contains another's (an annual over its + sets. ``entity_id`` keeps one entity's sets. Sets whose window strictly contains another's (an annual over its months) are dropped first. Then per ``period_end``: actual beats forecast, canonical beats publication snapshot, narrower window beats wider, newest wins. @@ -327,6 +346,7 @@ def load_statement_fact_set_series( FactSet.structure_id == structure_id, FactSet.factset_type == "report", scenario_predicate, + *_entity_filter(entity_id), ) .order_by( FactSet.period_end.asc(), @@ -519,13 +539,14 @@ def load_base_envelope_atoms( expected_block_type: str, fact_set_id: str | None = None, scenario_id: str | None = None, + entity_id: str | None = None, ) -> BaseEnvelopeAtoms | None: """Load every atom shared by Information Block envelope builders. ``None`` when the Structure is missing, isn't ``expected_block_type``, or a ``fact_set_id`` pin doesn't belong to it. A pin overrides - ``scenario_id``; without one, ``scenario_id`` selects the slice (``None`` - = actuals). + ``scenario_id`` and ``entity_id``; without one, ``scenario_id`` selects + the slice (``None`` = actuals) and ``entity_id`` whose sets. """ from robosystems.models.extensions import Taxonomy @@ -538,7 +559,9 @@ def load_base_envelope_atoms( if fact_set is None: return None else: - fact_set = load_latest_fact_set_for_structure(session, structure_id, scenario_id) + fact_set = load_latest_fact_set_for_structure( + session, structure_id, scenario_id, entity_id + ) taxonomy_name = session.execute( select(Taxonomy.name).where(Taxonomy.id == structure.taxonomy_id) @@ -574,11 +597,12 @@ def load_base_envelope_atoms( session, [a.id for a in associations] ) - # A pinned set scopes results by its own scenario. + # A pinned set scopes results by its own scenario and entity. verification_results = load_verification_results_for_structure( session, structure_id, scenario_id=fact_set.scenario_id if fact_set is not None else scenario_id, + entity_id=fact_set.entity_id if fact_set is not None else entity_id, ) return BaseEnvelopeAtoms( diff --git a/robosystems/operations/information_block/forecast.py b/robosystems/operations/information_block/forecast.py index 4d2233c4b..19f72974d 100644 --- a/robosystems/operations/information_block/forecast.py +++ b/robosystems/operations/information_block/forecast.py @@ -48,6 +48,7 @@ window_month_axis, ) from robosystems.operations.information_block.forecast_history import ( + LeverHistory, back_solve_lever_history, ) from robosystems.operations.information_block.metrics import ( @@ -754,6 +755,7 @@ def build_envelope( series: bool = False, series_history: int | None = None, series_forecast: int | None = None, + entity_id: str | None = None, ) -> InformationBlockEnvelope | None: """Reload a forecast Structure and pack its envelope as the assumptions grid. @@ -806,7 +808,15 @@ def build_envelope( horizon_months = [ add_months(mechanics.base_period, i) for i in range(1, mechanics.horizon_months + 1) ] - history = back_solve_lever_history(session, mechanics) + # A scenario is its entity's own: its history is that entity's books, + # whichever entity the caller is looking at. + lever_set = _load_lever_fact_set(session, structure_id) + owner_entity_id = lever_set.entity_id if lever_set is not None else entity_id + history = ( + back_solve_lever_history(session, mechanics, owner_entity_id) + if owner_entity_id + else LeverHistory() + ) actual_months = set(history.months) months = sorted(actual_months | set(horizon_months)) forecast_months = {month for month in horizon_months if month not in actual_months} @@ -899,7 +909,6 @@ def growth_value(entry: LineGrowthLite, month: str) -> float | None: unmapped_count=0, ) - lever_set = _load_lever_fact_set(session, structure_id) facts: list[Fact] = [] if lever_set is not None: facts = list( diff --git a/robosystems/operations/information_block/forecast_compute.py b/robosystems/operations/information_block/forecast_compute.py index 445f8d96f..dcce33485 100644 --- a/robosystems/operations/information_block/forecast_compute.py +++ b/robosystems/operations/information_block/forecast_compute.py @@ -342,7 +342,7 @@ def cmd_compute_forecast( assertion_values.setdefault(fact.element_id, {})[month] = float(fact.value) # ── Resolve the actual structures + seed month ──────────────────────── - is_structure_id = newest_actual_structure_id(session, "income_statement") + is_structure_id = newest_actual_structure_id(session, "income_statement", entity_id) if is_structure_id is None: raise ValueError( "No actual income-statement sets exist to project from — close at " @@ -350,7 +350,7 @@ def cmd_compute_forecast( "sets). If months are already closed without statements, set up " "the CoA mapping and reporting style, then reclose." ) - bs_structure_id = newest_actual_structure_id(session, "balance_sheet") + bs_structure_id = newest_actual_structure_id(session, "balance_sheet", entity_id) anchor_period = mechanics.base_period if mechanics.base_anchor == "seam": @@ -547,7 +547,9 @@ def _element_by_qname(qname: str) -> Element | None: mapping_id = prov.get("mapping_id") if mapping_id: break - cf_structure_id = newest_actual_structure_id(session, "cash_flow_statement") + cf_structure_id = newest_actual_structure_id( + session, "cash_flow_statement", entity_id + ) diagnostics: list[str] = [] diff --git a/robosystems/operations/information_block/forecast_history.py b/robosystems/operations/information_block/forecast_history.py index 78355a2fa..30fd17c94 100644 --- a/robosystems/operations/information_block/forecast_history.py +++ b/robosystems/operations/information_block/forecast_history.py @@ -95,15 +95,19 @@ def numeric_facts(session: Session, fact_set_id: str) -> list[Fact]: ) -def newest_actual_structure_id(session: Session, block_type: str) -> str | None: - """Structure behind the newest actual report set of a block type (not by - name: reporting styles have several variants per block type).""" +def newest_actual_structure_id( + session: Session, block_type: str, entity_id: str +) -> str | None: + """Structure behind an entity's newest actual report set of a block type + (not by name: reporting styles have several variants per block type, and + the group's entities can each use a different one).""" return session.execute( select(FactSet.structure_id) .join(Structure, FactSet.structure_id == Structure.id) .where( FactSet.factset_type == "report", FactSet.scenario_id.is_(None), + FactSet.entity_id == entity_id, Structure.block_type == block_type, ) .order_by(FactSet.created_at.desc()) @@ -148,9 +152,10 @@ class _SolvableRule: def back_solve_lever_history( - session: Session, mechanics: ForecastMechanics + session: Session, mechanics: ForecastMechanics, entity_id: str ) -> LeverHistory: - """Realized lever values for every closed month, by rule inversion. + """Realized lever values for every closed month of the entity's books, by + rule inversion. Returns an empty history when the tenant has no actual statement sets, no rs-driver rules, or nothing to solve; callers then render the @@ -181,7 +186,7 @@ def element_by_qname(qname: str) -> Element | None: needed_element_ids.update(rule.operand_element_ids.values()) needed_element_ids.update(rule.prior_element_ids.values()) - months, actuals = _actual_monthly_values(session, needed_element_ids) + months, actuals = _actual_monthly_values(session, needed_element_ids, entity_id) if not months: return LeverHistory() @@ -322,9 +327,10 @@ def _back_solve( def _actual_monthly_values( - session: Session, element_ids: set[str] + session: Session, element_ids: set[str], entity_id: str ) -> tuple[list[str], dict[str, dict[str, float]]]: - """Actual values per closed month for exactly the elements asked for. + """One entity's actual values per closed month for exactly the elements + asked for. Months come from the FactSet series, not the facts, so a month with no value is still a (blank) column. @@ -335,11 +341,11 @@ def _actual_monthly_values( return [], by_month for block_type in _ACTUAL_BLOCK_TYPES: - structure_id = newest_actual_structure_id(session, block_type) + structure_id = newest_actual_structure_id(session, block_type, entity_id) if structure_id is None: continue # Same loader as the statement columns, so months line up. - series = load_statement_fact_set_series(session, structure_id, None) + series = load_statement_fact_set_series(session, structure_id, None, entity_id) if not series: continue month_by_set = { diff --git a/robosystems/operations/information_block/metric.py b/robosystems/operations/information_block/metric.py index 82f0d733a..4c1857f2c 100644 --- a/robosystems/operations/information_block/metric.py +++ b/robosystems/operations/information_block/metric.py @@ -43,12 +43,14 @@ def _load_metric_fact_sets( structure_id: str, fact_set_id: str | None, scenario_id: str | None = None, + entity_id: str | None = None, ) -> list[FactSet]: """The structure's standing metric FactSets, one per period_end, oldest first (or just ``fact_set_id`` when pinned). - A ``scenario_id`` adds that scenario's sets; actuals win at a shared - period_end, then newest. + ``entity_id`` keeps one entity's sets: the metric catalog is shared by + the group. A ``scenario_id`` adds that scenario's sets; actuals win at a + shared period_end, then newest. """ if fact_set_id is not None: row = session.get(FactSet, fact_set_id) @@ -62,6 +64,7 @@ def _load_metric_fact_sets( FactSet.scenario_id.is_(None) if scenario_id is None else FactSet.scenario_id.is_(None) | (FactSet.scenario_id == scenario_id), + *((FactSet.entity_id == entity_id,) if entity_id is not None else ()), ) .order_by( FactSet.period_end.asc(), @@ -86,6 +89,7 @@ def build_envelope( series: bool = False, series_history: int | None = None, series_forecast: int | None = None, + entity_id: str | None = None, ) -> InformationBlockEnvelope | None: """Pack a metric Structure + its standing time series into the envelope. @@ -99,12 +103,15 @@ def build_envelope( expected_block_type=METRIC_BLOCK_TYPE, fact_set_id=fact_set_id, scenario_id=scenario_id, + entity_id=entity_id, ) if atoms is None: return None structure = atoms.structure - fact_sets = _load_metric_fact_sets(session, structure_id, fact_set_id, scenario_id) + fact_sets = _load_metric_fact_sets( + session, structure_id, fact_set_id, scenario_id, entity_id + ) facts: list[Fact] = [] if fact_sets: diff --git a/robosystems/operations/information_block/reads.py b/robosystems/operations/information_block/reads.py index 497a07239..84b828ff0 100644 --- a/robosystems/operations/information_block/reads.py +++ b/robosystems/operations/information_block/reads.py @@ -3,7 +3,7 @@ from __future__ import annotations -from sqlalchemy import or_, select +from sqlalchemy import func, or_, select from sqlalchemy.orm import Session from robosystems.models.api.information_block import InformationBlockEnvelope @@ -11,6 +11,57 @@ from robosystems.models.extensions.roboledger import FactSet from robosystems.operations.information_block import registry as registry_module from robosystems.operations.information_block.envelope import DISCLOSURE_BLOCK_TYPE +from robosystems.operations.roboledger.entity_scope import find_entity_id + + +def _owner_entity_id_column(): + """The entity a block is its own, as a column over ``Structure``. + + Schedules and reconciliations carry it on the row; a forecast's is its + lever set's (the ``custom`` set whose scenario is the block itself). NULL + for a block shared by the group: the statements, the metric catalog, the + library. + """ + lever_entity = ( + select(FactSet.entity_id) + .where( + FactSet.structure_id == Structure.id, + FactSet.factset_type == "custom", + FactSet.scenario_id == Structure.id, + ) + .order_by(FactSet.created_at.desc()) + .limit(1) + .correlate(Structure) + .scalar_subquery() + ) + return func.coalesce(Structure.entity_id, lever_entity) + + +def owner_entity_id(session: Session, structure_id: str) -> str | None: + """The entity whose own block this is, or None for a shared block.""" + return session.execute( + select(_owner_entity_id_column()).where(Structure.id == structure_id) + ).scalar() + + +def _requested_entity_id( + session: Session, + entity_id: str | None, + scenario_id: str | None, + library_sentinel: bool, +) -> str | None: + """The entity a read asks for: the named one (validated), else the + scenario's own, else the group parent. None on the library and on a graph + with no entity yet, where no set is anyone's to filter.""" + if library_sentinel: + return None + if entity_id: + return find_entity_id(session, entity_id) + if scenario_id: + scenario_owner = owner_entity_id(session, scenario_id) + if scenario_owner is not None: + return scenario_owner + return find_entity_id(session) def get_information_block( @@ -21,6 +72,8 @@ def get_information_block( series: bool = False, series_history: int | None = None, series_forecast: int | None = None, + entity_id: str | None = None, + library_sentinel: bool = False, ) -> InformationBlockEnvelope | None: """Fetch one block by id, dispatching via the structure's block_type. @@ -28,6 +81,11 @@ def get_information_block( A ``fact_set_id`` pin overrides ``scenario_id`` and ``series``. ``scenario_id`` (a forecast block's id; ``None`` = actuals) and the ``series*`` options are ignored by block types they don't apply to. + + ``entity_id`` picks whose sets a block shared by the group shows; omitted, + the scenario's entity, else the group parent. A block that is one + entity's own always shows its owner's. A named entity outside the graph + raises :class:`EntityNotInGraphError`. """ structure = session.get(Structure, structure_id) if structure is None: @@ -36,7 +94,10 @@ def get_information_block( entry = registry_module.get(structure.block_type) except KeyError: return None - return entry.dispatch_build_envelope( + scope = _requested_entity_id(session, entity_id, scenario_id, library_sentinel) + if not library_sentinel: + scope = owner_entity_id(session, structure_id) or scope + envelope = entry.dispatch_build_envelope( session, structure_id, fact_set_id, @@ -44,7 +105,21 @@ def get_information_block( series=series, series_history=series_history, series_forecast=series_forecast, + entity_id=scope, ) + return _stamp_entity(envelope, scope) + + +def _stamp_entity( + envelope: InformationBlockEnvelope | None, entity_id: str | None +) -> InformationBlockEnvelope | None: + """Name the entity whose books the envelope read; a pinned set names its + own.""" + if envelope is None: + return None + if envelope.fact_set is not None and envelope.fact_set.entity_id: + entity_id = envelope.fact_set.entity_id + return envelope.model_copy(update={"entity_id": entity_id}) def get_information_block_for_fact_set( @@ -66,12 +141,18 @@ def list_information_blocks( offset: int = 0, library_sentinel: bool = False, scenario_id: str | None = None, + entity_id: str | None = None, ) -> list[InformationBlockEnvelope]: """List blocks with optional block_type + category filters. ``library_sentinel`` (the ``library`` graph) restricts results to block types with ``surfaces_in_library``. ``scenario_id`` affects each envelope's binding, not which structures are listed. + + ``entity_id`` (omitted: the scenario's entity, else the group parent) + lists the blocks shared by the group plus that entity's own, never another + entity's schedules, reconciliations or forecasts; shared blocks show its + sets. """ if block_type is not None: try: @@ -103,8 +184,10 @@ def list_information_blocks( .where(Association.association_type == "presentation") .exists() ) + scope = _requested_entity_id(session, entity_id, scenario_id, library_sentinel) + owner = _owner_entity_id_column() query = ( - select(Structure) + select(Structure, owner) .where(Structure.block_type.in_(candidate_ids)) .where(Structure.is_active.is_(True)) .where(or_(Structure.block_type != DISCLOSURE_BLOCK_TYPE, has_presentation_arc)) @@ -116,16 +199,22 @@ def list_information_blocks( .limit(limit) .offset(offset) ) - rows = session.execute(query).scalars().all() + if scope is not None: + query = query.where(or_(owner.is_(None), owner == scope)) + rows = session.execute(query).all() envelopes: list[InformationBlockEnvelope] = [] - for structure in rows: + for structure, owner_id in rows: try: entry = registry_module.get(structure.block_type) except KeyError: continue - envelope = entry.dispatch_build_envelope( - session, structure.id, None, scenario_id=scenario_id + block_scope = owner_id or scope + envelope = _stamp_entity( + entry.dispatch_build_envelope( + session, structure.id, None, scenario_id=scenario_id, entity_id=block_scope + ), + block_scope, ) if envelope is not None: envelopes.append(envelope) @@ -136,4 +225,5 @@ def list_information_blocks( "get_information_block", "get_information_block_for_fact_set", "list_information_blocks", + "owner_entity_id", ] diff --git a/robosystems/operations/information_block/reconciliation.py b/robosystems/operations/information_block/reconciliation.py index 68bde05ae..e8bcf970e 100644 --- a/robosystems/operations/information_block/reconciliation.py +++ b/robosystems/operations/information_block/reconciliation.py @@ -52,10 +52,13 @@ def own_rule_id(structure_id: Any) -> Any: def standing_fact_sets( - session: Session, structure_id: str, fact_set_id: str | None = None + session: Session, + structure_id: str, + fact_set_id: str | None = None, + entity_id: str | None = None, ) -> list[FactSet]: """The block's standing comparison per period_end, oldest first (or just - ``fact_set_id`` when pinned).""" + ``fact_set_id`` when pinned), of ``entity_id`` when named.""" if fact_set_id is not None: row = session.get(FactSet, fact_set_id) return [row] if row is not None else [] @@ -66,6 +69,7 @@ def standing_fact_sets( FactSet.structure_id == structure_id, FactSet.factset_type == RECONCILIATION_FACTSET_TYPE, FactSet.scenario_id.is_(None), + *((FactSet.entity_id == entity_id,) if entity_id is not None else ()), ) .order_by(FactSet.period_end.asc(), FactSet.created_at.desc()) ) @@ -86,6 +90,7 @@ def build_envelope( series: bool = False, series_history: int | None = None, series_forecast: int | None = None, + entity_id: str | None = None, ) -> InformationBlockEnvelope | None: """Pack a reconciliation Structure and its period-by-period comparisons. @@ -97,12 +102,13 @@ def build_envelope( structure_id, expected_block_type=RECONCILIATION_BLOCK_TYPE, fact_set_id=fact_set_id, + entity_id=entity_id, ) if atoms is None: return None structure = atoms.structure - fact_sets = standing_fact_sets(session, structure_id, fact_set_id) + fact_sets = standing_fact_sets(session, structure_id, fact_set_id, entity_id) facts: list[Fact] = [] if fact_sets: facts = list( diff --git a/robosystems/operations/information_block/rollforward.py b/robosystems/operations/information_block/rollforward.py index 42ffb65f1..eed2e02a0 100644 --- a/robosystems/operations/information_block/rollforward.py +++ b/robosystems/operations/information_block/rollforward.py @@ -202,6 +202,7 @@ def build_envelope( series: bool = False, series_history: int | None = None, series_forecast: int | None = None, + entity_id: str | None = None, ) -> InformationBlockEnvelope | None: """Reload a rollforward Structure and pack its envelope (no facts; ``scenario_id`` is ignored). ``None`` when not found or not a rollforward. @@ -211,6 +212,7 @@ def build_envelope( structure_id, expected_block_type=ROLLFORWARD_BLOCK_TYPE, fact_set_id=fact_set_id, + entity_id=entity_id, ) if atoms is None: return None diff --git a/robosystems/operations/information_block/schedule.py b/robosystems/operations/information_block/schedule.py index 69c30aa09..9a03a8aee 100644 --- a/robosystems/operations/information_block/schedule.py +++ b/robosystems/operations/information_block/schedule.py @@ -132,6 +132,7 @@ def build_envelope( series: bool = False, series_history: int | None = None, series_forecast: int | None = None, + entity_id: str | None = None, ) -> InformationBlockEnvelope | None: """Reload a schedule Structure and pack its Information Block envelope. @@ -144,6 +145,7 @@ def build_envelope( structure_id, expected_block_type=SCHEDULE_BLOCK_TYPE, fact_set_id=fact_set_id, + entity_id=entity_id, ) if atoms is None: return None diff --git a/robosystems/operations/information_block/statement.py b/robosystems/operations/information_block/statement.py index 6cdb3f952..1ca303b40 100644 --- a/robosystems/operations/information_block/statement.py +++ b/robosystems/operations/information_block/statement.py @@ -79,6 +79,7 @@ def _build_statement_envelope( series: bool = False, series_history: int | None = None, series_forecast: int | None = None, + entity_id: str | None = None, *, block_type: str, ) -> InformationBlockEnvelope | None: @@ -101,6 +102,7 @@ def _build_statement_envelope( expected_block_type=block_type, fact_set_id=fact_set_id, scenario_id=scenario_id, + entity_id=entity_id, ) if atoms is None: return None @@ -112,7 +114,9 @@ def _build_statement_envelope( forecast_period_ends: set[date] = set() facts: list[Fact] = [] if element_ids and series_mode: - series_sets = load_statement_fact_set_series(session, structure_id, scenario_id) + series_sets = load_statement_fact_set_series( + session, structure_id, scenario_id, entity_id + ) series_sets = window_series_sets(series_sets, series_history, series_forecast) forecast_period_ends = { fs.period_end for fs in series_sets if fs.scenario_id is not None diff --git a/robosystems/operations/information_block/text_block.py b/robosystems/operations/information_block/text_block.py index faa925a4f..417f270cf 100644 --- a/robosystems/operations/information_block/text_block.py +++ b/robosystems/operations/information_block/text_block.py @@ -39,10 +39,12 @@ def build_text_block_envelope( session: Session, structure_id: str, fact_set_id: str | None = None, + entity_id: str | None = None, ) -> InformationBlockEnvelope | None: """Pack the envelope for a text-block disclosure structure. - Facts come from ``fact_set_id`` when pinned, else the latest FactSet. + Facts come from ``fact_set_id`` when pinned, else the latest FactSet + (of ``entity_id``, when named). ``None`` when the structure is missing, isn't a ``regulatory_disclosure``, or has neither content nor arcs; an unbound note renders empty. """ @@ -51,6 +53,7 @@ def build_text_block_envelope( structure_id, expected_block_type=DISCLOSURE_BLOCK_TYPE, fact_set_id=fact_set_id, + entity_id=entity_id, ) if atoms is None: return None diff --git a/tests/middleware/mcp/tools/test_information_block_tools.py b/tests/middleware/mcp/tools/test_information_block_tools.py index 93e6ee316..3aad4c636 100644 --- a/tests/middleware/mcp/tools/test_information_block_tools.py +++ b/tests/middleware/mcp/tools/test_information_block_tools.py @@ -180,6 +180,7 @@ async def test_passes_pagination_args_to_ops(self, mock_graph_client): offset=5, library_sentinel=False, scenario_id=None, + entity_id=None, ) @pytest.mark.asyncio @@ -200,4 +201,5 @@ async def test_passes_library_sentinel_for_library_graph(self, mock_graph_client offset=0, library_sentinel=True, scenario_id=None, + entity_id=None, ) diff --git a/tests/operations/information_block/test_entity_scope_db.py b/tests/operations/information_block/test_entity_scope_db.py new file mode 100644 index 000000000..ab5c23837 --- /dev/null +++ b/tests/operations/information_block/test_entity_scope_db.py @@ -0,0 +1,343 @@ +"""Block reads in a reporting group show one entity's books. + +Statement structures come from the reporting style and are shared by the +group, so two entities on the same style write sets against one structure. +A read that picks "the newest set of this structure" then shows whichever +entity closed last. These tests hold a parent and a subsidiary on one +structure and check every read keeps them apart. +""" + +from __future__ import annotations + +import os +import uuid +from datetime import date + +import pytest +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker + +import robosystems.models.extensions # noqa: F401 (register models) +from robosystems.db.extensions import ExtensionsBase +from robosystems.models.api.information_block import ForecastMechanics +from robosystems.operations.information_block.forecast_history import ( + newest_actual_structure_id, +) +from robosystems.operations.information_block.reads import ( + get_information_block, + list_information_blocks, +) +from robosystems.operations.roboledger.entity_scope import EntityNotInGraphError +from robosystems.operations.roboledger.fact_set import create_fact_set + +pytestmark = pytest.mark.unit + +PARENT = "ent_parent" +SUB = "ent_sub" +JUNE = (date(2026, 6, 1), date(2026, 6, 30)) +JULY = (date(2026, 7, 1), date(2026, 7, 31)) + + +@pytest.fixture() +def ext_session(): + """Extensions schema in the test Postgres DB, one throwaway schema per test.""" + database_url = os.environ.get("TEST_DATABASE_URL") + if not database_url: + pytest.skip("TEST_DATABASE_URL not configured") + + schema = f"ext_scope_{uuid.uuid4().hex[:12]}" + engine = create_engine(database_url) + with engine.begin() as conn: + conn.execute(text(f'CREATE SCHEMA "{schema}"')) + + session = sessionmaker(bind=engine)() + session.execute(text(f'SET search_path TO "{schema}"')) + ExtensionsBase.metadata.create_all(bind=session.connection()) + session.commit() + session.execute(text(f'SET search_path TO "{schema}"')) + + try: + yield session + finally: + session.rollback() + session.close() + with engine.begin() as conn: + conn.execute(text(f'DROP SCHEMA "{schema}" CASCADE')) + engine.dispose() + + +class _Group: + """A parent and a subsidiary sharing one balance sheet structure.""" + + def __init__(self, session) -> None: + from robosystems.models.extensions import ( + Association, + Element, + Entity, + Taxonomy, + ) + + self.session = session + session.add_all( + [ + Entity( + id=PARENT, name="Parent Co", is_parent=True, source="native", created_by="t" + ), + Entity( + id=SUB, + name="Sub LLC", + is_parent=False, + parent_entity_id=PARENT, + source="native", + created_by="t", + ), + ] + ) + taxonomy = Taxonomy(name="rs-gaap", taxonomy_type="reporting_extension") + session.add(taxonomy) + session.flush() + self.taxonomy_id = taxonomy.id + + self.balance_sheet = self.structure("Balance Sheet", "balance_sheet") + self.assets = Element( + name="Assets", + qname="rs-gaap:Assets", + balance_type="debit", + period_type="instant", + taxonomy_id=taxonomy.id, + ) + self.cash = Element( + name="Cash", + qname="rs-gaap:Cash", + balance_type="debit", + period_type="instant", + taxonomy_id=taxonomy.id, + ) + session.add_all([self.assets, self.cash]) + session.flush() + session.add( + Association( + structure_id=self.balance_sheet.id, + from_element_id=self.assets.id, + to_element_id=self.cash.id, + association_type="presentation", + order_value=1.0, + ) + ) + session.flush() + + def structure(self, name: str, block_type: str, **kwargs): + from robosystems.models.extensions import Structure + + structure = Structure( + name=name, block_type=block_type, taxonomy_id=self.taxonomy_id, **kwargs + ) + self.session.add(structure) + self.session.flush() + return structure + + def add_report_set( + self, entity_id: str, period, cash: float, structure=None, scenario_id=None + ) -> str: + from robosystems.models.extensions.roboledger import Fact + + structure = structure or self.balance_sheet + period_start, period_end = period + provenance = ( + { + "origin": "forecast", + "scenario_structure_id": scenario_id, + "base_period": "2026-06", + "month_index": 1, + "drivers": [], + } + if scenario_id + else {"origin": "pivot", "mapping_id": "m", "period": "2026-06"} + ) + fact_set = create_fact_set( + self.session, + structure_id=structure.id, + period_start=period_start, + period_end=period_end, + factset_type="report", + entity_id=entity_id, + scenario_id=scenario_id, + provenance=provenance, + created_by="t", + ) + self.session.flush() + self.session.add( + Fact( + element_id=self.cash.id, + value=cash, + fact_type="Numeric", + period_start=period_start, + period_end=period_end, + period_type="instant", + entity_id=entity_id, + structure_id=structure.id, + fact_set_id=fact_set.id, + ) + ) + self.session.flush() + return fact_set.id + + def add_forecast(self, entity_id: str, name: str = "Plan"): + """A forecast block whose lever set, its entity's record, names + ``entity_id``.""" + mechanics = ForecastMechanics( + scenario_kind="budget", horizon_months=1, base_period="2026-06", levers=[] + ) + forecast = self.structure( + name, "forecast", artifact_mechanics=mechanics.model_dump(mode="json") + ) + create_fact_set( + self.session, + structure_id=forecast.id, + period_end=JULY[1], + factset_type="custom", + entity_id=entity_id, + scenario_id=forecast.id, + provenance={ + "origin": "asserted", + "source_system": "forecast-levers", + "asserted_by": "t", + }, + created_by="t", + ) + self.session.flush() + return forecast + + +def _cash_values(envelope) -> list: + rendering = envelope.view.rendering + assert rendering is not None + (cash,) = [row for row in rendering.rows if row.element_qname == "rs-gaap:Cash"] + return list(cash.values) + + +class TestSharedStatement: + def test_defaults_to_the_parent_even_when_the_sub_closed_last( + self, ext_session + ) -> None: + group = _Group(ext_session) + parent_set = group.add_report_set(PARENT, JUNE, 100.0) + group.add_report_set(SUB, JULY, 7.0) + + envelope = get_information_block(ext_session, group.balance_sheet.id) + + assert envelope is not None + assert envelope.entity_id == PARENT + assert envelope.fact_set is not None + assert envelope.fact_set.id == parent_set + assert 7.0 not in _cash_values(envelope) + + def test_names_the_subsidiary(self, ext_session) -> None: + group = _Group(ext_session) + group.add_report_set(PARENT, JULY, 100.0) + sub_set = group.add_report_set(SUB, JUNE, 7.0) + + envelope = get_information_block(ext_session, group.balance_sheet.id, entity_id=SUB) + + assert envelope is not None + assert envelope.entity_id == SUB + assert envelope.fact_set is not None + assert envelope.fact_set.id == sub_set + + def test_series_holds_one_entity(self, ext_session) -> None: + group = _Group(ext_session) + group.add_report_set(PARENT, JUNE, 100.0) + group.add_report_set(SUB, JUNE, 7.0) + group.add_report_set(SUB, JULY, 8.0) + + parent = get_information_block(ext_session, group.balance_sheet.id, series=True) + sub = get_information_block( + ext_session, group.balance_sheet.id, series=True, entity_id=SUB + ) + + assert parent is not None and sub is not None + assert _cash_values(parent) == [100.0] + assert _cash_values(sub) == [7.0, 8.0] + + def test_an_entity_outside_the_graph_is_refused(self, ext_session) -> None: + group = _Group(ext_session) + + with pytest.raises(EntityNotInGraphError): + get_information_block(ext_session, group.balance_sheet.id, entity_id="ent_nope") + + def test_a_scenario_reads_its_own_entitys_books(self, ext_session) -> None: + group = _Group(ext_session) + forecast = group.add_forecast(SUB) + group.add_report_set(PARENT, JUNE, 100.0) + group.add_report_set(SUB, JUNE, 7.0) + group.add_report_set(SUB, JULY, 9.0, scenario_id=forecast.id) + + envelope = get_information_block( + ext_session, group.balance_sheet.id, scenario_id=forecast.id, series=True + ) + + assert envelope is not None + assert envelope.entity_id == SUB + assert _cash_values(envelope) == [7.0, 9.0] + + +class TestListing: + def test_lists_only_the_entitys_own_forecasts(self, ext_session) -> None: + group = _Group(ext_session) + parent_plan = group.add_forecast(PARENT, "Parent plan") + sub_plan = group.add_forecast(SUB, "Sub plan") + + parent = list_information_blocks(ext_session, block_type="forecast") + sub = list_information_blocks(ext_session, block_type="forecast", entity_id=SUB) + + assert [(b.id, b.entity_id) for b in parent] == [(parent_plan.id, PARENT)] + assert [(b.id, b.entity_id) for b in sub] == [(sub_plan.id, SUB)] + + def test_shared_blocks_list_for_every_entity_with_its_sets(self, ext_session) -> None: + group = _Group(ext_session) + group.add_report_set(PARENT, JUNE, 100.0) + sub_set = group.add_report_set(SUB, JUNE, 7.0) + + blocks = list_information_blocks( + ext_session, block_type="balance_sheet", entity_id=SUB + ) + + assert [b.id for b in blocks] == [group.balance_sheet.id] + assert blocks[0].entity_id == SUB + assert blocks[0].fact_set is not None + assert blocks[0].fact_set.id == sub_set + + def test_a_subsidiarys_schedule_reads_its_own_books_by_id(self, ext_session) -> None: + group = _Group(ext_session) + schedule = group.structure( + "Sub prepaid", + "rollforward", + entity_id=SUB, + artifact_mechanics={ + "kind": "rollforward", + "bs_source_element_id": group.cash.id, + "bs_source_qname": group.cash.qname, + }, + ) + + assert list_information_blocks(ext_session, block_type="rollforward") == [] + envelope = get_information_block(ext_session, schedule.id) + assert envelope is not None + assert envelope.entity_id == SUB + + +class TestForecastHistory: + def test_newest_actual_structure_is_the_entitys_own(self, ext_session) -> None: + """Each entity can be on a different style, so a different structure.""" + group = _Group(ext_session) + parent_is = group.structure("Income Statement (corp)", "income_statement") + sub_is = group.structure("Income Statement (llc)", "income_statement") + group.add_report_set(PARENT, JUNE, 1.0, structure=parent_is) + group.add_report_set(SUB, JULY, 2.0, structure=sub_is) + + assert newest_actual_structure_id(ext_session, "income_statement", PARENT) == ( + parent_is.id + ) + assert newest_actual_structure_id(ext_session, "income_statement", SUB) == ( + sub_is.id + ) diff --git a/tests/operations/information_block/test_forecast_compute.py b/tests/operations/information_block/test_forecast_compute.py index 469bc3989..7cf6fb73c 100644 --- a/tests/operations/information_block/test_forecast_compute.py +++ b/tests/operations/information_block/test_forecast_compute.py @@ -320,7 +320,7 @@ def _run( structure = _structure(mechanics) recorder = _Recorder() - def _structures(session: Any, block_type: str) -> str | None: + def _structures(session: Any, block_type: str, entity_id: str) -> str | None: return {"income_statement": "struct_is", "balance_sheet": bs_structure}.get( block_type ) diff --git a/tests/operations/information_block/test_forecast_handlers.py b/tests/operations/information_block/test_forecast_handlers.py index 792b10b1d..fd40048a6 100644 --- a/tests/operations/information_block/test_forecast_handlers.py +++ b/tests/operations/information_block/test_forecast_handlers.py @@ -1067,6 +1067,7 @@ def _history_envelope( "struct_budget_01", series_history=series_history, series_forecast=series_forecast, + entity_id="ent_1", ) def test_realized_months_extend_the_grid_behind_the_seam(self) -> None: diff --git a/tests/operations/information_block/test_forecast_history.py b/tests/operations/information_block/test_forecast_history.py index c6fded97c..f6d9d9c20 100644 --- a/tests/operations/information_block/test_forecast_history.py +++ b/tests/operations/information_block/test_forecast_history.py @@ -129,7 +129,7 @@ def _run( patch.object(fh, "_lookup_element", lambda _s, qname: BY_QNAME.get(qname)), patch.object(fh, "_actual_monthly_values", return_value=(months, actuals)), ): - return fh.back_solve_lever_history(_session(), mechanics) + return fh.back_solve_lever_history(_session(), mechanics, "ent_1") class TestBackSolve: @@ -274,7 +274,7 @@ def test_reads_only_the_elements_asked_for_across_the_series(self) -> None: patch.object(fh, "newest_actual_structure_id", side_effect=["struct_is", None]), patch.object(fh, "load_statement_fact_set_series", return_value=series), ): - months, actuals = fh._actual_monthly_values(session, {"el_rev"}) + months, actuals = fh._actual_monthly_values(session, {"el_rev"}, "ent_1") assert months == ["2026-04", "2026-05"] assert actuals == {"2026-04": {"el_rev": 100.0}, "2026-05": {"el_rev": 104.0}} @@ -295,13 +295,13 @@ def test_a_month_with_no_binding_fact_is_still_a_column(self) -> None: patch.object(fh, "newest_actual_structure_id", side_effect=["struct_is", None]), patch.object(fh, "load_statement_fact_set_series", return_value=series), ): - months, actuals = fh._actual_monthly_values(session, {"el_rev"}) + months, actuals = fh._actual_monthly_values(session, {"el_rev"}, "ent_1") assert months == ["2026-04", "2026-05"] assert "2026-05" not in actuals def test_no_actual_structures_means_no_months(self) -> None: with patch.object(fh, "newest_actual_structure_id", return_value=None): - months, actuals = fh._actual_monthly_values(MagicMock(), {"el_rev"}) + months, actuals = fh._actual_monthly_values(MagicMock(), {"el_rev"}, "ent_1") assert months == [] assert actuals == {} diff --git a/tests/operations/information_block/test_reads.py b/tests/operations/information_block/test_reads.py index 80b8071cf..7a8f9f3e2 100644 --- a/tests/operations/information_block/test_reads.py +++ b/tests/operations/information_block/test_reads.py @@ -47,6 +47,25 @@ def _schedule_entry_with_build(mock_build: MagicMock): return dataclasses.replace(SCHEDULE_BLOCK, dispatch_build_envelope=mock_build) +READS_PATH = "robosystems.operations.information_block.reads" +PARENT = "ent_parent" + + +@pytest.fixture(autouse=True) +def _entity_scope(): + """Mocked sessions answer no entity queries: the group parent is + ``PARENT`` and no block is one entity's own.""" + with ( + patch(f"{READS_PATH}.find_entity_id", return_value=PARENT), + patch(f"{READS_PATH}.owner_entity_id", return_value=None), + ): + yield + + +def _stamped(envelope: InformationBlockEnvelope) -> InformationBlockEnvelope: + return envelope.model_copy(update={"entity_id": PARENT}) + + # ── get_information_block ────────────────────────────────────────────────── @@ -76,7 +95,7 @@ def test_dispatches_to_schedule_handler(self) -> None: patched = _schedule_entry_with_build(mock_build) with patch.dict(REGISTRY_PATH, {"schedule": patched}): result = get_information_block(session, "struct_1") - assert result is expected + assert result == _stamped(expected) mock_build.assert_called_once_with( session, "struct_1", @@ -85,6 +104,7 @@ def test_dispatches_to_schedule_handler(self) -> None: series=False, series_history=None, series_forecast=None, + entity_id=PARENT, ) def test_series_threads_through_dispatch(self) -> None: @@ -113,6 +133,7 @@ def test_series_threads_through_dispatch(self) -> None: series=True, series_history=12, series_forecast=6, + entity_id=PARENT, ) @@ -144,7 +165,7 @@ def test_library_sentinel_surfaces_only_opt_in_block_types(self) -> None: opt-in types and skip the rest.""" session = MagicMock() # No library structures seeded in this unit test → empty result. - session.execute.return_value.scalars.return_value.all.return_value = [] + session.execute.return_value.all.return_value = [] result = list_information_blocks(session, library_sentinel=True) assert result == [] # One query attempted — against the 4 statement block types only. @@ -157,7 +178,7 @@ def test_tenant_query_includes_registered_block_types(self) -> None: structure = MagicMock() structure.id = "struct_1" structure.block_type = "schedule" - session.execute.return_value.scalars.return_value.all.return_value = [structure] + session.execute.return_value.all.return_value = [(structure, None)] expected = _envelope("struct_1") mock_build = MagicMock(return_value=expected) patched = _schedule_entry_with_build(mock_build) @@ -166,7 +187,7 @@ def test_tenant_query_includes_registered_block_types(self) -> None: session.execute.assert_called_once() assert len(result) == 1 - assert result[0] is expected + assert result[0] == _stamped(expected) def test_listing_query_guards_arcless_disclosure_rows(self) -> None: """The listing query excludes regulatory_disclosure structures with no @@ -178,7 +199,7 @@ def test_listing_query_guards_arcless_disclosure_rows(self) -> None: def _execute(stmt, *args, **kwargs): captured.append(str(stmt)) result = MagicMock() - result.scalars.return_value.all.return_value = [] + result.all.return_value = [] return result session.execute.side_effect = _execute @@ -221,9 +242,9 @@ def test_skips_structures_whose_block_type_is_unregistered(self) -> None: coa_row = MagicMock() coa_row.id = "struct_coa" coa_row.block_type = "chart_of_accounts" # not in registry yet - session.execute.return_value.scalars.return_value.all.return_value = [ - schedule_row, - coa_row, + session.execute.return_value.all.return_value = [ + (schedule_row, None), + (coa_row, None), ] expected = _envelope("struct_s") mock_build = MagicMock(return_value=expected) @@ -239,7 +260,7 @@ def test_library_sentinel_balance_sheet_filter_queries_statement_types( """When ``block_type='balance_sheet'`` is supplied on the sentinel, ``surfaces_in_library=True`` → query runs, scoped to the one type.""" session = MagicMock() - session.execute.return_value.scalars.return_value.all.return_value = [] + session.execute.return_value.all.return_value = [] result = list_information_blocks( session, block_type="balance_sheet", library_sentinel=True ) @@ -257,7 +278,7 @@ def test_list_surfaces_statement_blocks_on_tenant_graph(self) -> None: bs_row = MagicMock() bs_row.id = "struct_balance_sheet" bs_row.block_type = "balance_sheet" - session.execute.return_value.scalars.return_value.all.return_value = [bs_row] + session.execute.return_value.all.return_value = [(bs_row, None)] expected = InformationBlockEnvelope( id="struct_balance_sheet", @@ -338,7 +359,7 @@ def test_dispatches_with_fact_set_pin(self) -> None: with patch.dict(REGISTRY_PATH, {"schedule": patched}): result = get_information_block_for_fact_set(session, "fs_01") - assert result is expected + assert result == _stamped(expected) mock_build.assert_called_once_with( session, "struct_1", @@ -347,4 +368,5 @@ def test_dispatches_with_fact_set_pin(self) -> None: series=False, series_history=None, series_forecast=None, + entity_id=PARENT, ) diff --git a/tests/operations/information_block/test_text_block_handlers.py b/tests/operations/information_block/test_text_block_handlers.py index cfc972fa6..242f4b7fe 100644 --- a/tests/operations/information_block/test_text_block_handlers.py +++ b/tests/operations/information_block/test_text_block_handlers.py @@ -141,7 +141,7 @@ def test_text_block_cap_routes_to_text_builder(self) -> None: ) as text_builder: result = disclosure_handlers.build_envelope(session, "struct_policy", "fs_pin") - text_builder.assert_called_once_with(session, "struct_policy", "fs_pin") + text_builder.assert_called_once_with(session, "struct_policy", "fs_pin", None) assert result is sentinel def test_level1_textblock_cap_routes_to_text_builder(self) -> None: From c4f59a965ab1b456d84ee881d5f87874c5dd3d9c Mon Sep 17 00:00:00 2001 From: "Joseph T. French" Date: Thu, 8 Oct 2026 23:38:53 -0500 Subject: [PATCH 2/3] feat(ledger): schedules, saved statements and mappings name their entity - create-information-block's schedule body takes entity_id; every schedule made through it used to land on the group parent. - statement(reportId) renders in the reporting style of the entity the report is about. - mappings lists each mapping with the entity whose chart it maps from, and takes entityId to keep one entity's; mappingCandidates narrows by the named entity's style. - Entity defaults read 'the group parent' throughout, not 'the earliest-created entity'. --- robosystems/graphql/resolvers/ledger.py | 45 +++++++++---- .../middleware/mcp/tools/text_block_tools.py | 2 +- robosystems/models/api/extensions/entity.py | 5 +- .../models/api/extensions/forecasts.py | 4 +- .../models/api/extensions/schedules.py | 8 +++ .../models/api/extensions/taxonomies.py | 7 +++ .../models/api/extensions/text_blocks.py | 2 +- robosystems/models/api/information_block.py | 6 +- .../operations/information_block/schedule.py | 4 +- .../operations/roboledger/reads/reports.py | 9 ++- .../operations/roboledger/reads/taxonomies.py | 32 ++++++++-- .../operations/taxonomy_block/coa_mappings.py | 19 ++++++ tests/graphql/extensions/test_ledger.py | 59 +++++++++++++++-- .../test_schedule_handlers.py | 17 +++++ .../roboledger/reads/test_reports_extra.py | 63 +++++++++++++++++++ .../taxonomy_block/test_coa_mappings_db.py | 45 +++++++++++++ 16 files changed, 288 insertions(+), 39 deletions(-) diff --git a/robosystems/graphql/resolvers/ledger.py b/robosystems/graphql/resolvers/ledger.py index d33ba35e2..95ee3f62c 100644 --- a/robosystems/graphql/resolvers/ledger.py +++ b/robosystems/graphql/resolvers/ledger.py @@ -69,7 +69,11 @@ from robosystems.models.api.extensions.reports import ( ReportLifecycle as PydanticReportLifecycle, ) -from robosystems.operations.roboledger.entity_scope import EntityNotInGraphError +from robosystems.operations.roboledger.entity_scope import ( + EntityNotInGraphError, + NoEntityError, + resolve_entity_id, +) from robosystems.operations.roboledger.fiscal_calendar import ( FiscalCalendarService, parse_period, @@ -135,7 +139,7 @@ trial_balance as reads_trial_balance, ) from robosystems.operations.roboledger.reports.network_picker import ( - load_primary_reporting_style, + load_entity_reporting_style, ) from robosystems.operations.roboledger.schedules import ScheduleService from robosystems.operations.taxonomy_block.chart_templates import ( @@ -885,32 +889,38 @@ def mapping_candidates( self, info: Info[GraphQLContext, None], classification: str, + entity_id: str | None = None, ) -> list[Element]: """The rs-gaap concepts a chart-of-accounts element may map to. - Limited to concepts that actually render under the active Reporting Style - — falling back to the rs-gaap-presentation set when no Style is seeded — - with statement-level subtotals excluded. It is the same candidate set the - mapping operator picks from, so a target that would land a fact on an - unreachable branch is never offered. + Limited to concepts that actually render under the entity's Reporting + Style — falling back to the rs-gaap-presentation set when no Style is + seeded — with statement-level subtotals excluded. It is the same candidate + set the mapping operator picks from, so a target that would land a fact on + an unreachable branch is never offered. Args: classification: The CoA element's EFS classification - asset, liability, equity, revenue, expense, gain or loss - whose eligible rs-gaap targets to return. + entity_id: The entity whose chart is being mapped. Omit for the group parent. """ # Narrow to what the entity's Reporting Style renders, so the picker matches # the renderer; with no entity, the rs-gaap-presentation set is used. try: with _open_session(info, "roboledger") as session: try: - reporting_style_id = load_primary_reporting_style(session) - except LookupError: + reporting_style_id = load_entity_reporting_style( + session, resolve_entity_id(session, entity_id) + ) + except NoEntityError: reporting_style_id = None rows = reads_taxonomies.suggest_mapping_candidates( session, trait=classification, reporting_style_id=reporting_style_id, ) + except EntityNotInGraphError as exc: + raise strawberry.exceptions.StrawberryGraphQLError(str(exc)) from exc except (ValueError, ProgrammingError): _raise_ledger_not_initialized() return [Element.from_pydantic(r) for r in rows] @@ -960,11 +970,20 @@ def structures( return StructureList.from_pydantic(response) @strawberry.field - def mappings(self, info: Info[GraphQLContext, None]) -> StructureList | None: - """List all active mapping structures.""" + def mappings( + self, info: Info[GraphQLContext, None], entity_id: str | None = None + ) -> StructureList | None: + """List the active mapping structures, each naming the entity whose chart + it maps from. + + Args: + entity_id: Keep only this entity's mappings. Omit for every entity's. + """ try: with _open_session(info, "roboledger") as session: - response = reads_taxonomies.list_mappings(session) + response = reads_taxonomies.list_mappings(session, entity_id) + except EntityNotInGraphError as exc: + raise strawberry.exceptions.StrawberryGraphQLError(str(exc)) from exc except (ValueError, ProgrammingError): _raise_ledger_not_initialized() return StructureList.from_pydantic(response) @@ -1327,7 +1346,7 @@ def statement( graph_id = require_graph_id(info) try: with _open_session(info, "roboledger") as session: - # get_statement resolves the primary entity's Reporting Style from + # get_statement resolves the report entity's Reporting Style from # this same session — no hop to the platform DB. response = reads_reports.get_statement( session, diff --git a/robosystems/middleware/mcp/tools/text_block_tools.py b/robosystems/middleware/mcp/tools/text_block_tools.py index 2ee755fba..4075d04e9 100644 --- a/robosystems/middleware/mcp/tools/text_block_tools.py +++ b/robosystems/middleware/mcp/tools/text_block_tools.py @@ -81,7 +81,7 @@ def get_tool_definition(self) -> dict[str, Any]: }, "entity_id": { "type": "string", - "description": "Optional entity id; defaults to the primary entity", + "description": "Optional entity id; omit for the group parent", }, }, "required": ["document_id", "structure_id", "period_start", "period_end"], diff --git a/robosystems/models/api/extensions/entity.py b/robosystems/models/api/extensions/entity.py index ce5884d5a..c2e378ba5 100644 --- a/robosystems/models/api/extensions/entity.py +++ b/robosystems/models/api/extensions/entity.py @@ -308,10 +308,7 @@ class ChangeReportingStyleRequest(BaseModel): ) entity_id: str | None = Field( None, - description=( - "Target entity. Omit to target the graph's primary " - "(earliest-created) entity — the single-entity default." - ), + description=("The entity whose style to change, by id. Omit for the group parent."), ) model_config = ConfigDict( diff --git a/robosystems/models/api/extensions/forecasts.py b/robosystems/models/api/extensions/forecasts.py index f7878dd08..5c9cae0cf 100644 --- a/robosystems/models/api/extensions/forecasts.py +++ b/robosystems/models/api/extensions/forecasts.py @@ -286,8 +286,8 @@ class CreateForecastRequest(BaseModel): entity_id: str | None = Field( None, description=( - "Entity the scenario belongs to. Defaults to the graph's " - "earliest-created entity (single-entity convention)." + "The entity the scenario belongs to, by id: its history and its " + "computed months are that entity's books. Omit for the group parent." ), ) diff --git a/robosystems/models/api/extensions/schedules.py b/robosystems/models/api/extensions/schedules.py index 0f2d99fe4..cb4d28c29 100644 --- a/robosystems/models/api/extensions/schedules.py +++ b/robosystems/models/api/extensions/schedules.py @@ -103,6 +103,14 @@ class ScheduleMetadataRequest(BaseModel): class CreateScheduleRequest(BaseModel): name: str = Field(..., description="Schedule name") + entity_id: str | None = Field( + None, + description=( + "The entity whose books the schedule belongs to, by id. Omit for the " + "group parent. Its facts, obligations and closing entries land in " + "that entity's books, against that entity's own accounts." + ), + ) taxonomy_id: str | None = Field( None, description="Taxonomy ID (auto-creates if omitted)" ) diff --git a/robosystems/models/api/extensions/taxonomies.py b/robosystems/models/api/extensions/taxonomies.py index bb710a64c..7c9426311 100644 --- a/robosystems/models/api/extensions/taxonomies.py +++ b/robosystems/models/api/extensions/taxonomies.py @@ -68,6 +68,13 @@ class StructureResponse(BaseModel): "into (e.g. `rs-gaap`). A chart holds one mapping per framework." ), ) + entity_id: str | None = Field( + None, + description=( + "`coa_mapping` only, in the mappings list: the entity whose chart the " + "mapping maps from, so whose reports it can produce." + ), + ) class StructureListResponse(BaseModel): diff --git a/robosystems/models/api/extensions/text_blocks.py b/robosystems/models/api/extensions/text_blocks.py index 5dcdc59be..fd1ff5f6e 100644 --- a/robosystems/models/api/extensions/text_blocks.py +++ b/robosystems/models/api/extensions/text_blocks.py @@ -52,7 +52,7 @@ class BindTextBlockRequest(BaseModel): period_end: date = Field(..., description="Reporting period end.") entity_id: str | None = Field( None, - description="Entity the fact belongs to; defaults to the primary entity.", + description="Entity the fact belongs to, by id; omit for the group parent.", ) @model_validator(mode="after") diff --git a/robosystems/models/api/information_block.py b/robosystems/models/api/information_block.py index a74c13eb2..ec2c0500d 100644 --- a/robosystems/models/api/information_block.py +++ b/robosystems/models/api/information_block.py @@ -1871,8 +1871,7 @@ class ComputeMetricsRequest(BaseModel): entity_id: str | None = Field( None, description=( - "Entity to compute for. Defaults to the graph's earliest-created " - "entity (the primary entity for single-entity graphs)." + "The entity whose metrics to compute, by id. Omit for the group parent." ), ) scenario_id: str | None = Field( @@ -1990,8 +1989,7 @@ class AssertMetricsRequest(BaseModel): entity_id: str | None = Field( None, description=( - "Entity to assert for. Defaults to the graph's earliest-created " - "entity (the primary entity for single-entity graphs)." + "The entity the asserted values belong to, by id. Omit for the group parent." ), ) source_system: str = Field( diff --git a/robosystems/operations/information_block/schedule.py b/robosystems/operations/information_block/schedule.py index 9a03a8aee..e6a862811 100644 --- a/robosystems/operations/information_block/schedule.py +++ b/robosystems/operations/information_block/schedule.py @@ -49,7 +49,9 @@ def create( create_schedule as cmd_create_schedule, ) - response = cmd_create_schedule(session, payload, created_by=created_by) + response = cmd_create_schedule( + session, payload, created_by=created_by, entity_id=payload.entity_id + ) return response.structure_id diff --git a/robosystems/operations/roboledger/reads/reports.py b/robosystems/operations/roboledger/reads/reports.py index f99792784..a4d41a008 100644 --- a/robosystems/operations/roboledger/reads/reports.py +++ b/robosystems/operations/roboledger/reads/reports.py @@ -746,7 +746,8 @@ def get_statement( Returns `None` when the report doesn't exist. Raises `StatementStructureNotFoundError` when the block_type isn't in the report's - taxonomy. ``reporting_style_id`` defaults to the primary entity's Style. + taxonomy. ``reporting_style_id`` defaults to the Style of the entity the + report is about (the group parent's for a report with none recorded). """ if block_type not in VALID_BLOCK_TYPES: raise ValueError( @@ -825,7 +826,11 @@ def get_statement( ) if reporting_style_id is None: - reporting_style_id = load_primary_reporting_style(session) + reporting_style_id = ( + load_entity_reporting_style(session, report_def.entity_id) + if report_def.entity_id + else load_primary_reporting_style(session) + ) grid = render_structure_view( session=session, facts=facts, diff --git a/robosystems/operations/roboledger/reads/taxonomies.py b/robosystems/operations/roboledger/reads/taxonomies.py index 4832b2168..ccc4e6803 100644 --- a/robosystems/operations/roboledger/reads/taxonomies.py +++ b/robosystems/operations/roboledger/reads/taxonomies.py @@ -35,6 +35,10 @@ efs_trait_by_element, liquidity_by_element, ) +from robosystems.operations.roboledger.entity_scope import ( + find_entity_id, + resolve_entity_id, +) from robosystems.operations.roboledger.entry_status import ( landed_entry_bindparam, ) @@ -47,6 +51,7 @@ BOOK_FRAMEWORK, in_block, mapping_frameworks, + mapping_owner_ids, ) @@ -520,7 +525,7 @@ def _load_rs_gaap_presentation_set(session: Session) -> set[str]: def _structure_to_response( - row: Structure, framework: str | None = None + row: Structure, framework: str | None = None, entity_id: str | None = None ) -> StructureResponse: return StructureResponse( id=row.id, @@ -530,6 +535,7 @@ def _structure_to_response( taxonomy_id=row.taxonomy_id, is_active=row.is_active, framework=framework, + entity_id=entity_id, ) @@ -558,8 +564,13 @@ def list_structures( # ── Mappings ────────────────────────────────────────────────────────────── -def list_mappings(session: Session) -> StructureListResponse: - """List active ``coa_mapping`` structures, the book mapping first.""" +def list_mappings( + session: Session, entity_id: str | None = None +) -> StructureListResponse: + """List active ``coa_mapping`` structures, the book mapping first, each with + the entity whose chart it maps from. ``entity_id`` keeps that entity's + mappings only; a named entity outside the graph raises + :class:`EntityNotInGraphError`.""" rows = ( session.execute( select(Structure) @@ -572,10 +583,21 @@ def list_mappings(session: Session) -> StructureListResponse: .scalars() .all() ) - frameworks = mapping_frameworks(session, [str(r.id) for r in rows]) + mapping_ids = [str(r.id) for r in rows] + frameworks = mapping_frameworks(session, mapping_ids) + # A chart no entity owns is the group parent's. + parent_id = find_entity_id(session) + owners = mapping_owner_ids(session, mapping_ids) + owner_of = {mid: owners.get(mid, parent_id) for mid in mapping_ids} + if entity_id: + wanted = resolve_entity_id(session, entity_id) + rows = [r for r in rows if owner_of[str(r.id)] == wanted] ordered = sorted(rows, key=lambda r: frameworks.get(str(r.id)) != BOOK_FRAMEWORK) return StructureListResponse( - structures=[_structure_to_response(r, frameworks.get(str(r.id))) for r in ordered] + structures=[ + _structure_to_response(r, frameworks.get(str(r.id)), owner_of[str(r.id)]) + for r in ordered + ] ) diff --git a/robosystems/operations/taxonomy_block/coa_mappings.py b/robosystems/operations/taxonomy_block/coa_mappings.py index 8101f3601..ad37c90ee 100644 --- a/robosystems/operations/taxonomy_block/coa_mappings.py +++ b/robosystems/operations/taxonomy_block/coa_mappings.py @@ -186,6 +186,25 @@ def mapping_owner_id(session: Session, mapping_id: str) -> str | None: ).scalar_one_or_none() +def mapping_owner_ids(session: Session, mapping_ids: list[str]) -> dict[str, str]: + """:func:`mapping_owner_id` for several mappings in one query. A mapping + absent from the result maps a chart no entity owns.""" + if not mapping_ids: + return {} + rows = session.execute( + select(Structure.id, EntityTaxonomy.entity_id) + .join(Taxonomy, Structure.taxonomy_id == Taxonomy.id) + .join(EntityTaxonomy, Taxonomy.source_taxonomy_id == EntityTaxonomy.taxonomy_id) + .where( + Structure.id.in_(mapping_ids), + EntityTaxonomy.basis == CHART_LINK_BASIS, + ) + .order_by(EntityTaxonomy.created_at.desc()) + ).all() + # Newest first, so the earliest link wins, as in mapping_owner_id. + return {str(mapping_id): str(entity_id) for mapping_id, entity_id in rows} + + def find_entity_mapping( session: Session, entity_id: str | None, framework: str = BOOK_FRAMEWORK ) -> Structure | None: diff --git a/tests/graphql/extensions/test_ledger.py b/tests/graphql/extensions/test_ledger.py index 122935b5e..0222c5d94 100644 --- a/tests/graphql/extensions/test_ledger.py +++ b/tests/graphql/extensions/test_ledger.py @@ -1446,12 +1446,16 @@ def _candidate(self): is_active=True, ) - def test_narrows_to_the_primary_reporting_style(self) -> None: + def test_narrows_to_the_parents_reporting_style(self) -> None: with ( patch( - "robosystems.graphql.resolvers.ledger.load_primary_reporting_style", + "robosystems.graphql.resolvers.ledger.resolve_entity_id", + return_value="ent_parent", + ) as resolve, + patch( + "robosystems.graphql.resolvers.ledger.load_entity_reporting_style", return_value="style_1", - ), + ) as style, patch( f"{_OPS}.taxonomies.suggest_mapping_candidates", return_value=[self._candidate()], @@ -1460,16 +1464,55 @@ def test_narrows_to_the_primary_reporting_style(self) -> None: result = _run('query { mappingCandidates(classification: "asset") { qname } }') assert result.errors is None assert result.data == {"mappingCandidates": [{"qname": "rs-gaap:Cash"}]} + assert resolve.call_args.args[1] is None + assert style.call_args.args[1] == "ent_parent" assert suggest.call_args.kwargs == { "trait": "asset", "reporting_style_id": "style_1", } + def test_narrows_to_a_named_entitys_reporting_style(self) -> None: + with ( + patch( + "robosystems.graphql.resolvers.ledger.resolve_entity_id", + return_value="ent_sub", + ) as resolve, + patch( + "robosystems.graphql.resolvers.ledger.load_entity_reporting_style", + return_value="style_llc", + ), + patch( + f"{_OPS}.taxonomies.suggest_mapping_candidates", return_value=[] + ) as suggest, + ): + result = _run( + 'query { mappingCandidates(classification: "asset", entityId: "ent_sub") ' + "{ qname } }" + ) + assert result.errors is None + assert resolve.call_args.args[1] == "ent_sub" + assert suggest.call_args.kwargs["reporting_style_id"] == "style_llc" + + def test_an_entity_outside_the_graph_is_an_error(self) -> None: + from robosystems.operations.roboledger.entity_scope import EntityNotInGraphError + + with patch( + "robosystems.graphql.resolvers.ledger.resolve_entity_id", + side_effect=EntityNotInGraphError("Entity 'x' not found in this graph."), + ): + result = _run( + 'query { mappingCandidates(classification: "asset", entityId: "x") { qname } }' + ) + assert result.errors is not None + assert "not found in this graph" in result.errors[0].message + def test_without_an_entity_falls_back_to_no_style(self) -> None: + from robosystems.operations.roboledger.entity_scope import NoEntityError + with ( patch( - "robosystems.graphql.resolvers.ledger.load_primary_reporting_style", - side_effect=LookupError("no entity"), + "robosystems.graphql.resolvers.ledger.resolve_entity_id", + side_effect=NoEntityError("no entity"), ), patch( f"{_OPS}.taxonomies.suggest_mapping_candidates", return_value=[] @@ -1678,7 +1721,11 @@ class TestNotInitializedAcrossReads: def test_schema_missing_is_a_typed_error(self, target, query) -> None: with ( patch( - "robosystems.graphql.resolvers.ledger.load_primary_reporting_style", + "robosystems.graphql.resolvers.ledger.resolve_entity_id", + return_value="ent_parent", + ), + patch( + "robosystems.graphql.resolvers.ledger.load_entity_reporting_style", return_value=None, ), patch(f"{_OPS}.{target}", side_effect=_SCHEMA_MISSING), diff --git a/tests/operations/information_block/test_schedule_handlers.py b/tests/operations/information_block/test_schedule_handlers.py index d1a7e4a0b..06af6fdce 100644 --- a/tests/operations/information_block/test_schedule_handlers.py +++ b/tests/operations/information_block/test_schedule_handlers.py @@ -103,6 +103,23 @@ def test_delegates_to_cmd_create_schedule_and_returns_structure_id(self) -> None assert args[0] is session assert args[1] is not None assert kwargs.get("created_by") == "usr_test" + assert kwargs.get("entity_id") is None + + def test_a_named_entity_reaches_the_command(self) -> None: + """The body's entity is the schedule's: without it every schedule made + through create-information-block landed on the group parent.""" + expected = ScheduleCreatedResponse( + structure_id="struct_abc", + name="Equipment Depreciation", + taxonomy_id="tax_01", + total_periods=3, + total_facts=6, + ) + body = _body().model_copy(update={"entity_id": "ent_sub"}) + with patch(f"{COMMANDS}.create_schedule", return_value=expected) as mock_cmd: + schedule_handlers.create(MagicMock(), body, "usr_test") + + assert mock_cmd.call_args.kwargs["entity_id"] == "ent_sub" class TestUpdate: diff --git a/tests/operations/roboledger/reads/test_reports_extra.py b/tests/operations/roboledger/reads/test_reports_extra.py index b97a0a953..71b590a39 100644 --- a/tests/operations/roboledger/reads/test_reports_extra.py +++ b/tests/operations/roboledger/reads/test_reports_extra.py @@ -463,6 +463,69 @@ def test_income_statement_still_renders_both(self): assert resp.facts[0].values == [500.0, 450.0] +class TestGetStatementStyle: + """A saved report renders in the reporting style of the entity it is + about, not the group parent's: a subsidiary can be on another style.""" + + def _render(self, report_entity_id: str | None): + from types import SimpleNamespace + + from robosystems.operations.roboledger.reads.reports import ( + StatementStructureNotFoundError, + ) + + report = MagicMock() + report.id = "rpt_01" + report.entity_id = report_entity_id + report.period_start = date(2026, 7, 1) + report.period_end = date(2026, 7, 31) + report.comparative = False + report.periods = None + session = MagicMock() + session.get.return_value = report + session.execute.return_value = iter( + [ + SimpleNamespace( + element_id="e_cash", + value=1.0, + period_start=None, + period_end=date(2026, 7, 31), + period_type="instant", + qname="rs-gaap:Cash", + name="Cash", + trait="asset", + balance_type="debit", + ) + ] + ) + module = "robosystems.operations.roboledger.reads.reports" + with ( + patch(f"{module}.load_entity_reporting_style", return_value="style_llc") as own, + patch(f"{module}.load_primary_reporting_style", return_value="style_corp"), + patch( + f"{module}.render_structure_view", + return_value=MagicMock(structure_id=""), + ) as render, + pytest.raises(StatementStructureNotFoundError), + ): + get_statement( + session, graph_id="kg_test", report_id="rpt_01", block_type="balance_sheet" + ) + return render.call_args.kwargs["reporting_style_id"], own + + @pytest.mark.unit + def test_renders_in_the_report_entitys_style(self): + style, own = self._render("ent_sub") + assert style == "style_llc" + own.assert_called_once() + assert own.call_args.args[1] == "ent_sub" + + @pytest.mark.unit + def test_a_report_with_no_entity_reads_as_the_parents(self): + style, _ = self._render(None) + assert style == "style_corp" + + class TestSavedComparativeStatements: """``get_statement`` on a comparative report renders every column; each column's cash flow is derived from its own opening balances.""" diff --git a/tests/operations/taxonomy_block/test_coa_mappings_db.py b/tests/operations/taxonomy_block/test_coa_mappings_db.py index 0dd89ef94..7e0e34d34 100644 --- a/tests/operations/taxonomy_block/test_coa_mappings_db.py +++ b/tests/operations/taxonomy_block/test_coa_mappings_db.py @@ -460,3 +460,48 @@ def test_two_active_mappings_into_one_framework_keep_the_mapped_one( assert empty is not None empty_anchor = ext_session.get(Taxonomy, empty.taxonomy_id) assert empty_anchor is not None and empty_anchor.is_active is False + + +class TestMappingsNameTheirEntity: + """Each entity keeps its own chart, so a mapping is one entity's: the list + says whose, and keeps one entity's when asked.""" + + def _group(self, ext_session) -> tuple[str, str]: + from robosystems.models.extensions import Entity + from tests.ledger_entity import seed_parent_entity + + parent = seed_parent_entity(ext_session) + ext_session.add( + Entity( + id="ent_sub", + name="Sub LLC", + ticker="SUB", + is_parent=False, + parent_entity_id=parent, + source="native", + created_by="test", + ) + ) + ext_session.flush() + chart_block.create(ext_session, _chart_payload(_mapping(GAAP_MAPPING)), "u") + sub_chart = _chart_payload(_mapping("Sub to US GAAP Mapping")) + for element in sub_chart.elements: + element.qname = element.qname.replace("coa:", "coa-sub:") + chart_block.create(ext_session, sub_chart, "u", entity_id="ent_sub") + return parent, "ent_sub" + + def test_each_mapping_names_the_entity_whose_chart_it_maps( + self, ext_session, library + ): + parent, sub = self._group(ext_session) + + listed = {s.name: s.entity_id for s in list_mappings(ext_session).structures} + + assert listed == {GAAP_MAPPING: parent, "Sub to US GAAP Mapping": sub} + + def test_one_entitys_mappings(self, ext_session, library): + _, sub = self._group(ext_session) + + listed = list_mappings(ext_session, sub).structures + + assert [(s.name, s.entity_id) for s in listed] == [("Sub to US GAAP Mapping", sub)] From b9665244075fb8ef4dd5aad2ab5f7de7d988b965 Mon Sep 17 00:00:00 2001 From: "Joseph T. French" Date: Thu, 8 Oct 2026 23:49:14 -0500 Subject: [PATCH 3/3] docs(ledger): the information block README names the entity scope --- robosystems/operations/information_block/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/robosystems/operations/information_block/README.md b/robosystems/operations/information_block/README.md index 7ad8f27d8..3fdc68cde 100644 --- a/robosystems/operations/information_block/README.md +++ b/robosystems/operations/information_block/README.md @@ -67,6 +67,8 @@ Eleven block types are registered. Not all of them are authored through `create- Each `build_envelope` follows the same pattern: load the structure, count runtime state, load atoms, call the helpers, assemble the envelope. +**Entity scope.** A graph is a reporting group, and statement structures come from the reporting style, so the group's entities write sets against the same structure. Every set loader therefore takes `entity_id`, and `reads.py` decides it once per read: a schedule, reconciliation or forecast is one entity's own (`Structure.entity_id`, or a forecast's lever set) and reads its owner's sets; a shared block reads the entity asked for, else the scenario's, else the group parent. The list leaves out other entities' own blocks, and the envelope's `entity_id` names the entity it read. Forecast history and `compute-forecast` pick the newest actual structure per entity, since each entity can be on a different style. + **N+1 note**: `list_information_blocks` calls `build_envelope` per row (roughly nine queries per block). Fine at current graph sizes; this is the path to batch before dozens of blocks per graph. ## Rule evaluation engine