← Files VeraARCHIVED FILE

modules/bilancio-xbrl-it/scripts/mapping_memory.py

7.57 KB · Oct 2, 2026 · 00:29 UTC

↓ Download file

#!/usr/bin/env python3
"""Tenant-isolated reuse of explicitly approved account mappings.

Exact tenant, client-key, source-template, and account-code matching is used
because cross-tenant isolation and precedence are audit requirements. This
module never infers account meaning from descriptions or code patterns.
"""

from __future__ import annotations

import hashlib
import json
import tempfile
from copy import deepcopy
from pathlib import Path
from typing import Any, Mapping

__all__ = ["mapping_candidates", "remember_approved_mappings"]

SCHEMA_VERSION = 1


def _canonical_hash(value: Any) -> str:
    encoded = json.dumps(
        value, ensure_ascii=False, sort_keys=True, separators=(",", ":")
    ).encode("utf-8")
    return hashlib.sha256(encoded).hexdigest()


def _client_key(case: Mapping[str, Any]) -> str:
    entity = case["entity"]
    stable = str(entity.get("client_id") or entity["tax_identifier"])
    material = f"{case['tenant_id']}\x00{stable}".encode("utf-8")
    return hashlib.sha256(material).hexdigest()


def _read_store(path: Path, tenant_id: str) -> dict[str, Any]:
    if path.is_symlink():
        raise ValueError("Mapping memory must not be read through a symbolic link")
    if not path.exists():
        return {
            "schema_version": SCHEMA_VERSION,
            "tenant_id": tenant_id,
            "client_mappings": [],
            "tenant_mappings": [],
        }
    if not path.is_file():
        raise ValueError("Mapping memory must be a regular JSON file")
    payload = json.loads(path.read_text(encoding="utf-8"))
    if payload.get("schema_version") != SCHEMA_VERSION:
        raise ValueError("Unsupported mapping-memory schema version")
    if payload.get("tenant_id") != tenant_id:
        raise ValueError("Cross-tenant mapping-memory access is forbidden")
    for key in ("client_mappings", "tenant_mappings"):
        if not isinstance(payload.get(key), list):
            raise ValueError(f"Mapping-memory field {key} must be an array")
    return payload


def mapping_candidates(
    case: Mapping[str, Any], memory_path: Path, source_system_template: str
) -> list[dict[str, Any]]:
    """Return exact approved candidates with client scope taking precedence."""

    template = source_system_template.strip()
    if not template:
        raise ValueError("A source-system template identifier is required")
    if memory_path.is_symlink():
        raise ValueError("Mapping memory must not be read through a symbolic link")
    store = _read_store(memory_path.resolve(), str(case["tenant_id"]))
    client_key = _client_key(case)
    client_lookup = {
        (item["source_system_template"], item["account_code"]): item
        for item in store["client_mappings"]
        if item.get("client_key") == client_key
    }
    tenant_lookup = {
        (item["source_system_template"], item["account_code"]): item
        for item in store["tenant_mappings"]
    }
    candidates: list[dict[str, Any]] = []
    for account in (case.get("trial_balance") or {}).get("entries", []):
        key = (template, account["account_code"])
        remembered = client_lookup.get(key) or tenant_lookup.get(key)
        if remembered is None:
            continue
        scope = "CLIENT" if key in client_lookup else "TENANT"
        candidates.append(
            {
                "account_id": account["account_id"],
                "account_code": account["account_code"],
                "candidate_source": f"APPROVED_{scope}_MEMORY",
                "confidence_band": "HIGH",
                "requires_review": True,
                "rationale": "Exact approved tenant-isolated mapping match",
                "allocations": deepcopy(remembered["allocations"]),
                "approved_snapshot_hash": remembered["approved_snapshot_hash"],
            }
        )
    return candidates


def remember_approved_mappings(
    case: Mapping[str, Any],
    memory_path: Path,
    source_system_template: str,
    actor: str,
) -> dict[str, Any]:
    """Persist accepted mappings from an immutable approval snapshot."""

    approval = case.get("approval")
    if not approval or case.get("state") not in {"APPROVED", "EXPORTED"}:
        raise ValueError("Only approved mappings may enter mapping memory")
    if _canonical_hash(approval.get("snapshot")) != approval.get("snapshot_hash"):
        raise ValueError("Approved mapping snapshot hash is invalid")
    template = source_system_template.strip()
    if not template:
        raise ValueError("A source-system template identifier is required")
    if memory_path.is_symlink():
        raise ValueError("Mapping memory must not be written through a symbolic link")
    target = memory_path.resolve()
    store = _read_store(target, str(case["tenant_id"]))
    snapshot = approval["snapshot"]
    accounts = {
        item["account_id"]: item
        for item in (snapshot.get("trial_balance") or {}).get("entries", [])
    }
    client_key = _client_key(case)
    remembered = 0
    for mapping in snapshot.get("mappings", []):
        if mapping.get("decision") != "ACCEPTED":
            continue
        account = accounts.get(mapping["account_id"])
        if account is None:
            raise ValueError("Approved mapping references a missing account")
        scope = str(mapping.get("memory_scope", "CLIENT")).upper()
        if scope not in {"CLIENT", "TENANT"}:
            raise ValueError("Mapping memory scope must be CLIENT or TENANT")
        collection_name = "client_mappings" if scope == "CLIENT" else "tenant_mappings"
        collection = store[collection_name]
        identity = (template, account["account_code"])
        if scope == "CLIENT":
            identity = (*identity, client_key)

        def same_identity(item: Mapping[str, Any]) -> bool:
            candidate: tuple[str, ...] = (
                str(item.get("source_system_template")),
                str(item.get("account_code")),
            )
            if scope == "CLIENT":
                candidate = (*candidate, str(item.get("client_key")))
            return candidate == identity

        collection[:] = [item for item in collection if not same_identity(item)]
        record = {
            "source_system_template": template,
            "account_code": account["account_code"],
            "allocations": [
                {
                    "canonical_line": allocation["canonical_line"],
                    "statement_section": allocation["statement_section"],
                    "xbrl_concept": allocation.get("xbrl_concept"),
                    "xbrl_sign_multiplier": allocation.get("xbrl_sign_multiplier", "1"),
                    "schedule_triggers": list(allocation.get("schedule_triggers", [])),
                }
                for allocation in mapping["allocations"]
            ],
            "approved_snapshot_hash": approval["snapshot_hash"],
            "approved_by": actor,
            "memory_scope": scope,
        }
        if scope == "CLIENT":
            record["client_key"] = client_key
        collection.append(record)
        remembered += 1
    target.parent.mkdir(parents=True, exist_ok=True)
    if target.is_symlink():
        raise ValueError("Refusing to write mapping memory through a symbolic link")
    with tempfile.NamedTemporaryFile(
        mode="w",
        encoding="utf-8",
        prefix=f".{target.name}.",
        suffix=".tmp",
        dir=target.parent,
        delete=False,
    ) as handle:
        handle.write(
            json.dumps(store, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
            + "\n"
        )
        temporary = Path(handle.name)
    temporary.replace(target)
    return {"remembered": remembered, "memory_path": str(target)}

SHA-256: 0e24fc16436a6433c01598d33ec195d173d3b15e5d6355c3e74f3072f3f422e8