Source code for cadrumo.domain.calculations.registry.formula_runtime_m100

"""Modelo 100 formula evaluators for the registry calculation runtime.

This module owns the Modelo 100 operations that evaluate imputed real-estate
income and the estimación-objetiva agraria índice cascade.  The generic
dispatcher in :mod:`.formula_runtime` imports these evaluators directly; this
is the canonical home for the Modelo 100 operation family rather than a
forwarding layer.
"""

from __future__ import annotations

from dataclasses import dataclass
from decimal import Decimal
from typing import TYPE_CHECKING

from ....core.casilla_id import CasillaId
from ....core.decimal.constants import ONE, ZERO
from .errors import RegistryValidationError
from .formula_runtime_ops import (
    numeric_casilla_value as _numeric_casilla_value,
)
from .formula_runtime_ops import (
    resolve_scalar_parameter as _resolve_scalar_parameter,
)
from .ids import ParameterId
from .schema_formula import FormulaExpression

if TYPE_CHECKING:
    from .formula_runtime import EvalContext as _EvalContext


@dataclass(frozen=True, slots=True)
class _M100ResolveImputedRentArgs:
    """Resolved registry ids for the M100 imputed-rent dispatcher."""

    catastral_value_casilla_id: CasillaId
    revised_flag_casilla_id: CasillaId
    disposal_days_casilla_id: CasillaId
    mixed_use_flag_casilla_id: CasillaId
    disposal_percentage_casilla_id: CasillaId
    mixed_use_days_casilla_id: CasillaId
    recent_rate_parameter: ParameterId
    old_rate_parameter: ParameterId
    year_days_parameter: ParameterId


[docs] def evaluate_m100_resolve_renta_inmobiliaria_imputada( expression: FormulaExpression, ctx: _EvalContext, ) -> Decimal: """Resolve M100 Art. 85 imputed real-estate income for cadastral-value rows. M100's 0083-0089 property row has the cadastral-value branch inputs: value, revised-value checkbox, days at disposal, and the mixed-use percentage/days override. The same row does not carry the no-cadastral substitute base (max of acquisition and administration-checked values), so that branch fails closed instead of inventing a base or silently returning zero for a positive imputation period. """ op = "m100_resolve_renta_inmobiliaria_imputada" args = _m100_resolve_imputed_rent_args(expression) catastral_value = _numeric_casilla_value(args.catastral_value_casilla_id, ctx) disposal_days = _numeric_casilla_value(args.disposal_days_casilla_id, ctx) mixed_use = _m100_boolean_casilla_value(args.mixed_use_flag_casilla_id, ctx, op=op) disposal_percentage = _numeric_casilla_value(args.disposal_percentage_casilla_id, ctx) mixed_use_days = _numeric_casilla_value(args.mixed_use_days_casilla_id, ctx) is_revised = _m100_revised_cadastral_value_flag(args.revised_flag_casilla_id, ctx) _m100_validate_imputed_rent_non_negative_inputs( args, catastral_value=catastral_value, disposal_days=disposal_days, disposal_percentage=disposal_percentage, mixed_use_days=mixed_use_days, ) no_catastral_result = _m100_resolve_no_catastral_imputation( args, catastral_value=catastral_value, disposal_days=disposal_days, mixed_use=mixed_use, disposal_percentage=disposal_percentage, mixed_use_days=mixed_use_days, ) if no_catastral_result is not None: return no_catastral_result effective_days, year_days, share = _m100_resolve_imputed_rent_period( args, ctx, op=op, disposal_days=disposal_days, mixed_use=mixed_use, disposal_percentage=disposal_percentage, mixed_use_days=mixed_use_days, ) rate = _m100_resolve_imputed_rent_rate(args, ctx, op=op, is_revised=is_revised) return catastral_value * rate * (effective_days / year_days) * share
def _m100_validate_imputed_rent_non_negative_inputs( args: _M100ResolveImputedRentArgs, *, catastral_value: Decimal, disposal_days: Decimal, disposal_percentage: Decimal, mixed_use_days: Decimal, ) -> None: if catastral_value < ZERO: raise RegistryValidationError( "M100 Art.85 valor catastral must be non-negative", translated_message="errors.calc.m100_art85_catastral_value_negative", context={"casilla_id": args.catastral_value_casilla_id, "value": str(catastral_value)}, ) for casilla_id, value in ( (args.disposal_days_casilla_id, disposal_days), (args.disposal_percentage_casilla_id, disposal_percentage), (args.mixed_use_days_casilla_id, mixed_use_days), ): if value < ZERO: raise RegistryValidationError( "M100 Art.85 numeric inputs must be non-negative", translated_message="errors.calc.m100_art85_input_negative", context={"casilla_id": casilla_id, "value": str(value)}, ) def _m100_resolve_no_catastral_imputation( args: _M100ResolveImputedRentArgs, *, catastral_value: Decimal, disposal_days: Decimal, mixed_use: bool, disposal_percentage: Decimal, mixed_use_days: Decimal, ) -> Decimal | None: if catastral_value != ZERO: return None if disposal_days > ZERO or mixed_use_days > ZERO or mixed_use or disposal_percentage > ZERO: raise RegistryValidationError( "M100 Art.85 no-catastral imputation requires substitute-base casillas that are not " "present in the 0083-0089 registry row", translated_message="errors.calc.m100_art85_no_catastral_base_missing", context={ "catastral_value_casilla_id": args.catastral_value_casilla_id, "disposal_days_casilla_id": args.disposal_days_casilla_id, "mixed_use_days_casilla_id": args.mixed_use_days_casilla_id, }, ) return ZERO def _m100_resolve_imputed_rent_period( args: _M100ResolveImputedRentArgs, ctx: _EvalContext, *, op: str, disposal_days: Decimal, mixed_use: bool, disposal_percentage: Decimal, mixed_use_days: Decimal, ) -> tuple[Decimal, Decimal, Decimal]: effective_days = mixed_use_days if mixed_use else disposal_days year_days = _resolve_scalar_parameter(args.year_days_parameter, ctx, op=op) _m100_validate_imputation_days( effective_days, casilla_id=args.mixed_use_days_casilla_id if mixed_use else args.disposal_days_casilla_id, max_days=year_days, max_days_parameter_id=args.year_days_parameter, ) _m100_validate_imputed_rent_mixed_use_inputs( args, mixed_use=mixed_use, disposal_percentage=disposal_percentage, mixed_use_days=mixed_use_days, ) share = disposal_percentage / Decimal("100") if mixed_use else ONE return effective_days, year_days, share def _m100_validate_imputed_rent_mixed_use_inputs( args: _M100ResolveImputedRentArgs, *, mixed_use: bool, disposal_percentage: Decimal, mixed_use_days: Decimal, ) -> None: if not mixed_use and (mixed_use_days != ZERO or disposal_percentage != ZERO): raise RegistryValidationError( "M100 Art.85 mixed-use days or percentage require casilla 0086 to be checked", translated_message="errors.calc.m100_art85_mixed_use_inputs_without_flag", context={ "mixed_use_flag_casilla_id": args.mixed_use_flag_casilla_id, "mixed_use_days_casilla_id": args.mixed_use_days_casilla_id, "disposal_percentage_casilla_id": args.disposal_percentage_casilla_id, }, ) if mixed_use and (disposal_percentage <= ZERO or disposal_percentage > Decimal("100")): raise RegistryValidationError( "M100 Art.85 mixed-use percentage must be in (0, 100]", translated_message="errors.calc.m100_art85_disposal_percentage_invalid", context={ "casilla_id": args.disposal_percentage_casilla_id, "value": str(disposal_percentage), }, ) def _m100_resolve_imputed_rent_rate( args: _M100ResolveImputedRentArgs, ctx: _EvalContext, *, op: str, is_revised: bool, ) -> Decimal: recent_rate = _resolve_scalar_parameter(args.recent_rate_parameter, ctx, op=op) old_rate = _resolve_scalar_parameter(args.old_rate_parameter, ctx, op=op) return recent_rate if is_revised else old_rate def _m100_resolve_imputed_rent_args(expression: FormulaExpression) -> _M100ResolveImputedRentArgs: op = "m100_resolve_renta_inmobiliaria_imputada" if len(expression.args) != 9: raise RegistryValidationError(f"formula op {op!r} expects 9 args, got {len(expression.args)}") ( catastral_value_arg, revised_flag_arg, disposal_days_arg, mixed_use_flag_arg, disposal_percentage_arg, mixed_use_days_arg, year_days_arg, recent_rate_arg, old_rate_arg, ) = expression.args return _M100ResolveImputedRentArgs( catastral_value_casilla_id=_m100_require_casilla_arg(catastral_value_arg, op=op, position=0), revised_flag_casilla_id=_m100_require_casilla_arg(revised_flag_arg, op=op, position=1), disposal_days_casilla_id=_m100_require_casilla_arg(disposal_days_arg, op=op, position=2), mixed_use_flag_casilla_id=_m100_require_casilla_arg(mixed_use_flag_arg, op=op, position=3), disposal_percentage_casilla_id=_m100_require_casilla_arg(disposal_percentage_arg, op=op, position=4), mixed_use_days_casilla_id=_m100_require_casilla_arg(mixed_use_days_arg, op=op, position=5), year_days_parameter=_m100_require_parameter_arg(year_days_arg, op=op, position=6), recent_rate_parameter=_m100_require_parameter_arg(recent_rate_arg, op=op, position=7), old_rate_parameter=_m100_require_parameter_arg(old_rate_arg, op=op, position=8), ) def _m100_require_casilla_arg(arg: FormulaExpression, *, op: str, position: int) -> CasillaId: casilla_id = arg.casilla_id if casilla_id is None: raise RegistryValidationError(f"formula op {op!r} requires args[{position}] to be a casilla leaf") return casilla_id def _m100_require_parameter_arg(arg: FormulaExpression, *, op: str, position: int) -> ParameterId: parameter_id = arg.parameter if parameter_id is None: raise RegistryValidationError(f"formula op {op!r} requires args[{position}] to be a parameter leaf") return parameter_id def _m100_revised_cadastral_value_flag(casilla_id: CasillaId, ctx: _EvalContext) -> bool: raw_value = ctx.text_values.get(casilla_id, "") ctx.operand_refs.append(casilla_id) ctx.operand_casilla_refs.append(casilla_id) if raw_value == "": return False normalised = raw_value.strip().upper() if normalised == "X": return True raise RegistryValidationError( "M100 Art.85 revised cadastral value flag must be the official X checkbox value", translated_message="errors.calc.m100_art85_revision_flag_invalid", context={"casilla_id": casilla_id, "value": raw_value}, ) def _m100_boolean_casilla_value(casilla_id: CasillaId, ctx: _EvalContext, *, op: str) -> bool: value = _numeric_casilla_value(casilla_id, ctx) if value not in {ZERO, ONE}: raise RegistryValidationError( "M100 Art.85 boolean casilla must be 0 or 1", translated_message="errors.calc.m100_art85_boolean_invalid", context={"casilla_id": casilla_id, "value": str(value), "op": op}, ) return value == ONE def _m100_validate_imputation_days( days: Decimal, *, casilla_id: CasillaId, max_days: Decimal, max_days_parameter_id: ParameterId, ) -> None: if max_days != max_days.to_integral_value() or max_days <= ZERO or max_days > Decimal("366"): raise RegistryValidationError( "M100 Art.85 imputation year-days parameter must be an integer in [1, 366]", translated_message="errors.calc.m100_art85_imputation_days_invalid", context={"parameter_id": max_days_parameter_id, "value": str(max_days), "max_days": "366"}, ) if days != days.to_integral_value() or days <= ZERO or days > max_days: raise RegistryValidationError( f"M100 Art.85 imputation days must be an integer in [1, {max_days}]", translated_message="errors.calc.m100_art85_imputation_days_invalid", context={"casilla_id": casilla_id, "value": str(days), "max_days": str(max_days)}, ) #: Índice-corrector casilla count the M100 estimación-objetiva agraria Fase 3ª #: dispatcher carries (Anexo I instrucción 2.3, letras a) to h) — índices 1 to 8; #: índice 9, mejillón en batea (letra i), applies to a separate producto #: (casilla 0160) outside this cascade). _M100_EO_AGRARIA_INDICE_COUNT = 8 @dataclass(frozen=True, slots=True) class _M100ResolveEoAgrariaIndicesCorrectoresArgs: """Resolved registry ids for the M100 EO-agraria Fase 3ª índices-correctores dispatcher.""" minorado_casilla_id: CasillaId indice_casilla_ids: tuple[CasillaId, ...] def _m100_resolve_eo_agraria_indices_correctores_args( expression: FormulaExpression, ) -> _M100ResolveEoAgrariaIndicesCorrectoresArgs: op = "m100_resolve_eo_agraria_indices_correctores" expected_arg_count = 1 + _M100_EO_AGRARIA_INDICE_COUNT if len(expression.args) != expected_arg_count: raise RegistryValidationError( f"formula op {op!r} expects {expected_arg_count} args, got {len(expression.args)}", translated_message="errors.calc.lookup_dispatch_arg_count", context={"op": op, "expected": str(expected_arg_count)}, ) minorado_arg = expression.args[0] if minorado_arg.casilla_id is None: raise RegistryValidationError( f"formula op {op!r} requires args[0] to be a casilla leaf", translated_message="errors.calc.lookup_dispatch_arg_kind", context={"op": op, "position": "args[0]", "expected_kind": "casilla"}, ) indice_casilla_ids: list[CasillaId] = [] for position, indice_arg in enumerate(expression.args[1:], start=1): if indice_arg.casilla_id is None: raise RegistryValidationError( f"formula op {op!r} requires args[{position}] to be a casilla leaf", translated_message="errors.calc.lookup_dispatch_arg_kind", context={"op": op, "position": f"args[{position}]", "expected_kind": "casilla"}, ) indice_casilla_ids.append(indice_arg.casilla_id) return _M100ResolveEoAgrariaIndicesCorrectoresArgs( minorado_casilla_id=minorado_arg.casilla_id, indice_casilla_ids=tuple(indice_casilla_ids), ) def _m100_eo_agraria_read_indice(casilla_id: CasillaId, ctx: _EvalContext) -> Decimal: """Read one Fase 3ª índice-corrector casilla, tolerating either declared type. Every índice casilla in the Anexo I instrucción 2.3 cascade (letras a) to h)) is a rate the operator/preparer reads off the Anexo table, but the AEAT Diseño de Registros declares one of the eight (índice 4, «piensos adquiridos a terceros», casilla 1543) with field type ``X`` (text) while the other seven use ``P012`` (decimal) — an AEAT dictionary quirk, not a semantic difference in the índice itself. A text-typed casilla's value only ever reaches :attr:`EvalContext.text_values`, never :attr:`EvalContext.values` (the numeric map defaults it to zero and never receives the operator's real figure), so reading it through :func:`~domain.calculations.registry.formula_runtime_ops.numeric_casilla_value` alone would silently and permanently treat índice 4 as never declared. Checking ``text_values`` first — and falling back to the numeric map only when the casilla is genuinely absent from ``text_values`` (true for every ``P012`` índice, which a caller never routes through ``text_inputs``) — lets the same cascade loop handle both declared types without a position-keyed special case. An unparsable or blank text value resolves to zero, the same "índice not applied" signal a blank decimal casilla gives. """ if casilla_id in ctx.text_values: ctx.operand_refs.append(casilla_id) ctx.operand_casilla_refs.append(casilla_id) raw_text = ctx.text_values[casilla_id].strip() try: value = Decimal(raw_text) if raw_text else ZERO except ArithmeticError: value = ZERO ctx.operand_values.append(value) return value return _numeric_casilla_value(casilla_id, ctx)
[docs] def evaluate_m100_resolve_eo_agraria_indices_correctores( expression: FormulaExpression, ctx: _EvalContext, ) -> Decimal: """Resolve the M100 estimación-objetiva agraria Fase 3ª índices correctores. Orden HAC/1347/2024, Anexo I instrucción 2.3 (letras a) to h)) fixes the mechanism: the rendimiento neto minorado (Fase 2ª, casilla 1539) is corrected by applying, in sequence, the índice or índices correctores that correspond to the activity — each índice applying "sobre el rendimiento neto minorado o, en su caso, sobre el rectificado por aplicación de los [índices] anteriores": a sequential cascade over the Anexo's own letra ordering (a → h), not a single-index pick nor a simultaneous product. Each índice casilla (1540 to 1547, one per letra a) to h)) is an operator/preparer-declared rate (AEAT Diseño de Registros field type ``P012`` for seven of the eight, ``X`` for índice 4 — see :func:`_m100_eo_agraria_read_indice`, fields ``E5AI1`` to ``E5AI8``) — the taxpayer reads the applicable índice off the Anexo I table for their activity and enters it directly, mirroring how the M131 estimación-objetiva módulos engine resolves its own índice corrector de exceso. A blank índice casilla resolves to zero (indistinguishable, at this op's boundary, from "not declared"); because every real índice in the Anexo I table is strictly positive (0,50 to 0,95), a non-positive read is treated as "índice not applied" — the cascade step is skipped (factor of 1, never a fabricated zero-out) rather than multiplying the accumulator by zero. This never over-states nor under-states the reduction: a real but undeclared índice simply goes uncredited, and a declared índice is applied exactly once, in the Anexo's own order. A non-positive rendimiento neto minorado never receives índices correctores and resolves to the minorado figure unchanged. Índice 9 (mejillón en batea, letra i) is not modelled by this dispatcher: it applies to a separate producto (casilla 0160) outside the 1539→1548 cascade. """ args = _m100_resolve_eo_agraria_indices_correctores_args(expression) minorado = _numeric_casilla_value(args.minorado_casilla_id, ctx) ctx.operand_refs.append(args.minorado_casilla_id) ctx.operand_casilla_refs.append(args.minorado_casilla_id) if minorado <= ZERO: return minorado rendimiento = minorado for indice_casilla_id in args.indice_casilla_ids: indice = _m100_eo_agraria_read_indice(indice_casilla_id, ctx) if indice <= ZERO: continue rendimiento = rendimiento * indice return rendimiento