From 2d3bfa301c1c82c366738ea1a00f162249440866 Mon Sep 17 00:00:00 2001 From: "Joseph T. French" Date: Wed, 14 Oct 2026 13:49:22 -0500 Subject: [PATCH] feat(ledger): reclassify a mapped account in place (replaces_association_id) Regenerated against robosystems #1804: replaces_association_id and reason on create-mapping-association, replaced_association_id and reclassified_periods on its response, created_at on FactSetLite, the schema refreshed to match. Claude-Session: https://claude.ai/code/session_014Wxgk4yttBxgdE2uBHT71R --- .../create_mapping_association.py | 36 +++++++++----- .../delete_mapping_association.py | 12 +++-- .../update_taxonomy_block.py | 16 +++++-- robosystems_client/graphql/schema.graphql | 20 +++++++- .../models/association_response.py | 36 ++++++++++++++ .../create_mapping_association_operation.py | 47 +++++++++++++++++++ robosystems_client/models/fact_set_lite.py | 32 +++++++++++++ 7 files changed, 177 insertions(+), 22 deletions(-) diff --git a/robosystems_client/api/robo_ledger_taxonomy_mapping/create_mapping_association.py b/robosystems_client/api/robo_ledger_taxonomy_mapping/create_mapping_association.py index 5fe5d359..2d54b191 100644 --- a/robosystems_client/api/robo_ledger_taxonomy_mapping/create_mapping_association.py +++ b/robosystems_client/api/robo_ledger_taxonomy_mapping/create_mapping_association.py @@ -118,9 +118,12 @@ def sync_detailed( Link a chart-of-accounts element to a US GAAP reporting concept. One mapping edge per call — use `auto-map-elements` for bulk AI-assisted mapping. Duplicate (from, to, type) tuples return 409. The target must be a leaf concept: a subtotal the statement sums from its children is refused (422). Map - before you close: an account with landed history in a closed month cannot be re-mapped (422) — the - closed month's stamped statements were computed through the old arcs. Reopen latest-first down to - the earliest month named, map, then close forward. + before you close: a new arc for an account with landed history in a closed month is refused (422), + since the month's stamped statements were computed through the arcs as they stood. To move such an + account, pass `replaces_association_id` (its current arc, removed in the same write). A move within + the same primary statement is a reclassification and goes through: the stamped statements keep the + answer as closed, live renders follow the new arc, and `reclassified_periods` names the months. A + move across statements restates net income and is refused, naming the months to reopen latest-first. **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. @@ -168,9 +171,12 @@ def sync( Link a chart-of-accounts element to a US GAAP reporting concept. One mapping edge per call — use `auto-map-elements` for bulk AI-assisted mapping. Duplicate (from, to, type) tuples return 409. The target must be a leaf concept: a subtotal the statement sums from its children is refused (422). Map - before you close: an account with landed history in a closed month cannot be re-mapped (422) — the - closed month's stamped statements were computed through the old arcs. Reopen latest-first down to - the earliest month named, map, then close forward. + before you close: a new arc for an account with landed history in a closed month is refused (422), + since the month's stamped statements were computed through the arcs as they stood. To move such an + account, pass `replaces_association_id` (its current arc, removed in the same write). A move within + the same primary statement is a reclassification and goes through: the stamped statements keep the + answer as closed, live renders follow the new arc, and `reclassified_periods` names the months. A + move across statements restates net income and is refused, naming the months to reopen latest-first. **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. @@ -213,9 +219,12 @@ async def asyncio_detailed( Link a chart-of-accounts element to a US GAAP reporting concept. One mapping edge per call — use `auto-map-elements` for bulk AI-assisted mapping. Duplicate (from, to, type) tuples return 409. The target must be a leaf concept: a subtotal the statement sums from its children is refused (422). Map - before you close: an account with landed history in a closed month cannot be re-mapped (422) — the - closed month's stamped statements were computed through the old arcs. Reopen latest-first down to - the earliest month named, map, then close forward. + before you close: a new arc for an account with landed history in a closed month is refused (422), + since the month's stamped statements were computed through the arcs as they stood. To move such an + account, pass `replaces_association_id` (its current arc, removed in the same write). A move within + the same primary statement is a reclassification and goes through: the stamped statements keep the + answer as closed, live renders follow the new arc, and `reclassified_periods` names the months. A + move across statements restates net income and is refused, naming the months to reopen latest-first. **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. @@ -261,9 +270,12 @@ async def asyncio( Link a chart-of-accounts element to a US GAAP reporting concept. One mapping edge per call — use `auto-map-elements` for bulk AI-assisted mapping. Duplicate (from, to, type) tuples return 409. The target must be a leaf concept: a subtotal the statement sums from its children is refused (422). Map - before you close: an account with landed history in a closed month cannot be re-mapped (422) — the - closed month's stamped statements were computed through the old arcs. Reopen latest-first down to - the earliest month named, map, then close forward. + before you close: a new arc for an account with landed history in a closed month is refused (422), + since the month's stamped statements were computed through the arcs as they stood. To move such an + account, pass `replaces_association_id` (its current arc, removed in the same write). A move within + the same primary statement is a reclassification and goes through: the stamped statements keep the + answer as closed, live renders follow the new arc, and `reclassified_periods` names the months. A + move across statements restates net income and is refused, naming the months to reopen latest-first. **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. diff --git a/robosystems_client/api/robo_ledger_taxonomy_mapping/delete_mapping_association.py b/robosystems_client/api/robo_ledger_taxonomy_mapping/delete_mapping_association.py index 9e57dec5..fdfda328 100644 --- a/robosystems_client/api/robo_ledger_taxonomy_mapping/delete_mapping_association.py +++ b/robosystems_client/api/robo_ledger_taxonomy_mapping/delete_mapping_association.py @@ -117,7 +117,8 @@ def sync_detailed( only the association row is dropped. Use this to correct a wrong mapping — delete the bad edge, then `create-mapping-association` the right one. Find the association id via the `library_element_arcs` GraphQL field. Library-seeded rows cannot be deleted (403). An edge for an account with landed - history in a closed month cannot be removed (422) until those months are reopened, latest-first. + history in a closed month cannot be removed on its own (422): move the account with `create-mapping- + association` and `replaces_association_id` instead. **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. @@ -162,7 +163,8 @@ def sync( only the association row is dropped. Use this to correct a wrong mapping — delete the bad edge, then `create-mapping-association` the right one. Find the association id via the `library_element_arcs` GraphQL field. Library-seeded rows cannot be deleted (403). An edge for an account with landed - history in a closed month cannot be removed (422) until those months are reopened, latest-first. + history in a closed month cannot be removed on its own (422): move the account with `create-mapping- + association` and `replaces_association_id` instead. **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. @@ -202,7 +204,8 @@ async def asyncio_detailed( only the association row is dropped. Use this to correct a wrong mapping — delete the bad edge, then `create-mapping-association` the right one. Find the association id via the `library_element_arcs` GraphQL field. Library-seeded rows cannot be deleted (403). An edge for an account with landed - history in a closed month cannot be removed (422) until those months are reopened, latest-first. + history in a closed month cannot be removed on its own (422): move the account with `create-mapping- + association` and `replaces_association_id` instead. **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. @@ -245,7 +248,8 @@ async def asyncio( only the association row is dropped. Use this to correct a wrong mapping — delete the bad edge, then `create-mapping-association` the right one. Find the association id via the `library_element_arcs` GraphQL field. Library-seeded rows cannot be deleted (403). An edge for an account with landed - history in a closed month cannot be removed (422) until those months are reopened, latest-first. + history in a closed month cannot be removed on its own (422): move the account with `create-mapping- + association` and `replaces_association_id` instead. **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. diff --git a/robosystems_client/api/robo_ledger_taxonomy_mapping/update_taxonomy_block.py b/robosystems_client/api/robo_ledger_taxonomy_mapping/update_taxonomy_block.py index 0b96354c..caa7c34a 100644 --- a/robosystems_client/api/robo_ledger_taxonomy_mapping/update_taxonomy_block.py +++ b/robosystems_client/api/robo_ledger_taxonomy_mapping/update_taxonomy_block.py @@ -122,7 +122,9 @@ def sync_detailed( `custom_ontology` authoring may be disabled per environment (TAXONOMY_AUTHORING_ENABLED) — disabled surfaces 403. Closed months are immutable against curation: a mapping arc added or removed for an account with landed history in a closed month, or a `balance_type` / `period_type` / `trait` change - on such an account, is refused (422, `protected_facts`) naming the months to reopen first. + on such an account, is refused (422, `protected_facts`) naming the months to reopen first. An + envelope that removes an account's arc and adds one on the same primary statement moves it: a + reclassification, allowed. **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. @@ -176,7 +178,9 @@ def sync( `custom_ontology` authoring may be disabled per environment (TAXONOMY_AUTHORING_ENABLED) — disabled surfaces 403. Closed months are immutable against curation: a mapping arc added or removed for an account with landed history in a closed month, or a `balance_type` / `period_type` / `trait` change - on such an account, is refused (422, `protected_facts`) naming the months to reopen first. + on such an account, is refused (422, `protected_facts`) naming the months to reopen first. An + envelope that removes an account's arc and adds one on the same primary statement moves it: a + reclassification, allowed. **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. @@ -225,7 +229,9 @@ async def asyncio_detailed( `custom_ontology` authoring may be disabled per environment (TAXONOMY_AUTHORING_ENABLED) — disabled surfaces 403. Closed months are immutable against curation: a mapping arc added or removed for an account with landed history in a closed month, or a `balance_type` / `period_type` / `trait` change - on such an account, is refused (422, `protected_facts`) naming the months to reopen first. + on such an account, is refused (422, `protected_facts`) naming the months to reopen first. An + envelope that removes an account's arc and adds one on the same primary statement moves it: a + reclassification, allowed. **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. @@ -277,7 +283,9 @@ async def asyncio( `custom_ontology` authoring may be disabled per environment (TAXONOMY_AUTHORING_ENABLED) — disabled surfaces 403. Closed months are immutable against curation: a mapping arc added or removed for an account with landed history in a closed month, or a `balance_type` / `period_type` / `trait` change - on such an account, is refused (422, `protected_facts`) naming the months to reopen first. + on such an account, is refused (422, `protected_facts`) naming the months to reopen first. An + envelope that removes an account's arc and adds one on the same primary statement moves it: a + reclassification, allowed. **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. diff --git a/robosystems_client/graphql/schema.graphql b/robosystems_client/graphql/schema.graphql index 75d32dab..54ea9a7d 100644 --- a/robosystems_client/graphql/schema.graphql +++ b/robosystems_client/graphql/schema.graphql @@ -510,8 +510,9 @@ type Query { ): MappingDetail """ - How one month's bank lines were classified, per suggestion source: - taken as suggested, overridden, split, or chosen with no suggestion to + How one month's bank lines were classified, per suggestion source. + + Taken as suggested, overridden, split, or chosen with no suggestion to take, and the share that needed a person's choice. Counted from the lines committed in the month; lines posted before outcomes were recorded are `unstamped` and outside the share. @@ -2491,6 +2492,16 @@ type Association { confidence: Float suggestedBy: String approvedBy: String + + """ + The arc this write removed in the same transaction, when the call named one in `replaces_association_id`. + """ + replacedAssociationId: String + + """ + Closed months whose live statements now read through this arc while their stamped sets keep the answer as closed. Empty unless the write replaced an arc on an account with landed history in a closed month. + """ + reclassifiedPeriods: [String!]! } """How a month's bank lines were classified, counted from the lines.""" @@ -3469,6 +3480,11 @@ type InformationBlockFactSet { Typed `FactProvenance` descriptor (discriminated on `origin`: pivot | schedule | derived | asserted | document | forecast | filed) recording how this FactSet was constructed. It describes the set, not each fact: a `pivot` set's mapped leaves aggregate posted line items, while its subtotals and derivations are computed from those leaves. Surfaced as JSON, mirroring how mechanics is exposed. Null when the FactSet carries no descriptor. """ provenance: JSON + + """ + When the set was computed. For a close-stamped canonical set (`reportId` null, type `report`) this is the month as closed on this date: its facts keep that answer while a live render reads through the mapping arcs as they stand now. + """ + createdAt: DateTime } """ diff --git a/robosystems_client/models/association_response.py b/robosystems_client/models/association_response.py index 72008d5c..49e7d1f5 100644 --- a/robosystems_client/models/association_response.py +++ b/robosystems_client/models/association_response.py @@ -37,6 +37,11 @@ class AssociationResponse: confidence (float | None | Unset): suggested_by (None | str | Unset): approved_by (None | str | Unset): + replaced_association_id (None | str | Unset): The arc this write removed in the same transaction, when the call + named one in `replaces_association_id`. + reclassified_periods (list[str] | Unset): Closed months whose live statements now read through this arc while + their stamped sets keep the answer as closed. Empty unless the write replaced an arc on an account with landed + history in a closed month. """ id: str @@ -53,6 +58,8 @@ class AssociationResponse: confidence: float | None | Unset = UNSET suggested_by: None | str | Unset = UNSET approved_by: None | str | Unset = UNSET + replaced_association_id: None | str | Unset = UNSET + reclassified_periods: list[str] | Unset = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: @@ -120,6 +127,16 @@ def to_dict(self) -> dict[str, Any]: else: approved_by = self.approved_by + replaced_association_id: None | str | Unset + if isinstance(self.replaced_association_id, Unset): + replaced_association_id = UNSET + else: + replaced_association_id = self.replaced_association_id + + reclassified_periods: list[str] | Unset = UNSET + if not isinstance(self.reclassified_periods, Unset): + reclassified_periods = self.reclassified_periods + field_dict: dict[str, Any] = {} field_dict.update(self.additional_properties) field_dict.update( @@ -149,6 +166,10 @@ def to_dict(self) -> dict[str, Any]: field_dict["suggested_by"] = suggested_by if approved_by is not UNSET: field_dict["approved_by"] = approved_by + if replaced_association_id is not UNSET: + field_dict["replaced_association_id"] = replaced_association_id + if reclassified_periods is not UNSET: + field_dict["reclassified_periods"] = reclassified_periods return field_dict @@ -246,6 +267,19 @@ def _parse_approved_by(data: object) -> None | str | Unset: approved_by = _parse_approved_by(d.pop("approved_by", UNSET)) + def _parse_replaced_association_id(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + replaced_association_id = _parse_replaced_association_id( + d.pop("replaced_association_id", UNSET) + ) + + reclassified_periods = cast(list[str], d.pop("reclassified_periods", UNSET)) + association_response = cls( id=id, structure_id=structure_id, @@ -261,6 +295,8 @@ def _parse_approved_by(data: object) -> None | str | Unset: confidence=confidence, suggested_by=suggested_by, approved_by=approved_by, + replaced_association_id=replaced_association_id, + reclassified_periods=reclassified_periods, ) association_response.additional_properties = d diff --git a/robosystems_client/models/create_mapping_association_operation.py b/robosystems_client/models/create_mapping_association_operation.py index 2d57c512..28ab4faa 100644 --- a/robosystems_client/models/create_mapping_association_operation.py +++ b/robosystems_client/models/create_mapping_association_operation.py @@ -38,6 +38,13 @@ class CreateMappingAssociationOperation: 0.70–0.89 flagged for review, <0.70 skipped. suggested_by (None | str | Unset): Source of the suggestion (e.g., 'mapping_agent', 'user'). Captured for audit; not used by the runtime. + replaces_association_id (None | str | Unset): The account's current mapping arc in this structure, removed in + the same write: a remap in one call. When the account has landed history in a closed month the move is judged as + a reclassification: allowed when the new concept sits on the same primary statement as the old one (the stamped + statements keep the answer as closed; live renders follow the new arc; `reclassified_periods` names the months), + refused when it crosses one. + reason (None | str | Unset): Why the account is reclassified. Recorded on the new arc beside the months it + reaches. """ from_element_id: str @@ -50,6 +57,8 @@ class CreateMappingAssociationOperation: weight: float | None | Unset = UNSET confidence: float | None | Unset = UNSET suggested_by: None | str | Unset = UNSET + replaces_association_id: None | str | Unset = UNSET + reason: None | str | Unset = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: @@ -87,6 +96,18 @@ def to_dict(self) -> dict[str, Any]: else: suggested_by = self.suggested_by + replaces_association_id: None | str | Unset + if isinstance(self.replaces_association_id, Unset): + replaces_association_id = UNSET + else: + replaces_association_id = self.replaces_association_id + + reason: None | str | Unset + if isinstance(self.reason, Unset): + reason = UNSET + else: + reason = self.reason + field_dict: dict[str, Any] = {} field_dict.update(self.additional_properties) field_dict.update( @@ -106,6 +127,10 @@ def to_dict(self) -> dict[str, Any]: field_dict["confidence"] = confidence if suggested_by is not UNSET: field_dict["suggested_by"] = suggested_by + if replaces_association_id is not UNSET: + field_dict["replaces_association_id"] = replaces_association_id + if reason is not UNSET: + field_dict["reason"] = reason return field_dict @@ -163,6 +188,26 @@ def _parse_suggested_by(data: object) -> None | str | Unset: suggested_by = _parse_suggested_by(d.pop("suggested_by", UNSET)) + def _parse_replaces_association_id(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + replaces_association_id = _parse_replaces_association_id( + d.pop("replaces_association_id", UNSET) + ) + + def _parse_reason(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + reason = _parse_reason(d.pop("reason", UNSET)) + create_mapping_association_operation = cls( from_element_id=from_element_id, to_element_id=to_element_id, @@ -172,6 +217,8 @@ def _parse_suggested_by(data: object) -> None | str | Unset: weight=weight, confidence=confidence, suggested_by=suggested_by, + replaces_association_id=replaces_association_id, + reason=reason, ) create_mapping_association_operation.additional_properties = d diff --git a/robosystems_client/models/fact_set_lite.py b/robosystems_client/models/fact_set_lite.py index 38738a34..60aa60a3 100644 --- a/robosystems_client/models/fact_set_lite.py +++ b/robosystems_client/models/fact_set_lite.py @@ -40,6 +40,9 @@ class FactSetLite: constructed. It describes the set, not each fact: a `pivot` set's mapped leaves aggregate posted line items, while its subtotals and derivations are computed from those leaves. Surfaced as JSON, mirroring how mechanics is exposed. Null when the FactSet carries no descriptor. + created_at (datetime.datetime | None | Unset): When the set was computed. For a close-stamped canonical set + (`report_id` null, type `report`) this is the month as closed on this date: its facts keep that answer while a + live render reads through the mapping arcs as they stand now. """ id: str @@ -51,6 +54,7 @@ class FactSetLite: report_id: None | str | Unset = UNSET scenario_id: None | str | Unset = UNSET provenance: FactSetLiteProvenanceType0 | None | Unset = UNSET + created_at: datetime.datetime | None | Unset = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: @@ -98,6 +102,14 @@ def to_dict(self) -> dict[str, Any]: else: provenance = self.provenance + created_at: None | str | Unset + if isinstance(self.created_at, Unset): + created_at = UNSET + elif isinstance(self.created_at, datetime.datetime): + created_at = self.created_at.isoformat() + else: + created_at = self.created_at + field_dict: dict[str, Any] = {} field_dict.update(self.additional_properties) field_dict.update( @@ -118,6 +130,8 @@ def to_dict(self) -> dict[str, Any]: field_dict["scenario_id"] = scenario_id if provenance is not UNSET: field_dict["provenance"] = provenance + if created_at is not UNSET: + field_dict["created_at"] = created_at return field_dict @@ -195,6 +209,23 @@ def _parse_provenance(data: object) -> FactSetLiteProvenanceType0 | None | Unset provenance = _parse_provenance(d.pop("provenance", UNSET)) + def _parse_created_at(data: object) -> datetime.datetime | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, str): + raise TypeError() + created_at_type_0 = datetime.datetime.fromisoformat(data) + + return created_at_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(datetime.datetime | None | Unset, data) + + created_at = _parse_created_at(d.pop("created_at", UNSET)) + fact_set_lite = cls( id=id, period_end=period_end, @@ -205,6 +236,7 @@ def _parse_provenance(data: object) -> FactSetLiteProvenanceType0 | None | Unset report_id=report_id, scenario_id=scenario_id, provenance=provenance, + created_at=created_at, ) fact_set_lite.additional_properties = d