dodil/erp-brick-fixed-assets

DODIL ERP brick: fixed assets

src/brick_fixed_assets/application/inputs.py

"""The bounded inputs of this brick's use cases (BRICKS.md: an unbounded int or str is a promise the engine cannot
keep). Money is NUMERIC(18,2): an exact string with at most two places and sixteen digits; a unit count NUMERIC(19,4); a
rate a percentage from 0 to 100; a life 1 to 1,200 periods. Each is a 422 naming the field, never a 500."""
from datetime import date
from decimal import Decimal, InvalidOperation
from typing import Annotated, Any, Optional

from erp_platform.types import BigId, IsoDate, Name, Note, text  # noqa: F401  (re-exported to the use cases)
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, WithJsonSchema

Code = text(64, min_length=1)
Blankable = text(64)          # a code the use case checks itself, so a blank is a gate body, not a bare 422
LedgerCode = Annotated[str, Field(pattern=r"^[A-Z0-9]{2,8}$")]   # gl's ledger code (shared.yaml: VARCHAR, e.g. IFRS, LOCAL)
Reason = text(500, min_length=1)
Currency = text(3, min_length=3)
Hex64 = Annotated[str, Field(pattern=r"^[0-9a-f]{64}$")]
Life = Annotated[int, Field(ge=1, le=1200)]
Seq = Annotated[int, Field(ge=1, le=1_000_000)]
Page = Annotated[int, Field(ge=1, le=1000)]
Period = Annotated[str, Field(pattern=r"^[0-9]{4}-(0[1-9]|1[0-2])$")]
Year = Annotated[int, Field(ge=1900, le=2199)]


def _decimal(v: Any, what: str, places: int, limit: Decimal, negative: bool) -> Decimal:
    if isinstance(v, bool) or isinstance(v, float):
        raise ValueError(f"{what} is an exact number: give it as a string such as \"120.00\"")
    try:
        d = v if isinstance(v, Decimal) else Decimal(str(v).strip())
    except (InvalidOperation, ValueError):
        raise ValueError(f"{v!r} is not a number") from None
    if not d.is_finite():
        raise ValueError(f"{what} is a finite number")
    if d.as_tuple().exponent < -places:
        raise ValueError(f"{what} has at most {places} decimal places")
    if abs(d) >= limit:
        raise ValueError(f"{what} is below {limit:,}")
    if d < 0 and not negative:
        raise ValueError(f"{what} is zero or more")
    return d


def _money(v: Any) -> Decimal:
    return _decimal(v, "an amount", 2, Decimal("1e16"), True)


def _money_pos(v: Any) -> Decimal:
    return _decimal(v, "an amount", 2, Decimal("1e16"), False)


def _units(v: Any) -> Decimal:
    return _decimal(v, "a unit count", 4, Decimal("1e15"), False)


def _pct(v: Any) -> Decimal:
    d = _decimal(v, "a percentage", 4, Decimal("1e6"), False)
    if d > 100:
        raise ValueError("a percentage is from 0 to 100")
    return d


def _rate(v: Any) -> Decimal:
    return _decimal(v, "a rate", 4, Decimal("1e14"), False)


def _schema(pattern: str, description: str):
    return WithJsonSchema({"type": "string", "pattern": pattern, "description": description})


Money = Annotated[Decimal, BeforeValidator(_money), _schema(r"^-?[0-9]{1,16}(\.[0-9]{1,2})?$",
                                                             "an exact amount, two places at most, a string (\"1250.00\"); may be negative")]
PosMoney = Annotated[Decimal, BeforeValidator(_money_pos), _schema(r"^[0-9]{1,16}(\.[0-9]{1,2})?$",
                                                                    "an exact amount, zero or more, two places at most, a string")]
Units = Annotated[Decimal, BeforeValidator(_units), _schema(r"^[0-9]{1,15}(\.[0-9]{1,4})?$",
                                                             "a unit count, four places at most, a string (\"410\")")]
Pct = Annotated[Decimal, BeforeValidator(_pct), _schema(r"^[0-9]{1,3}(\.[0-9]{1,4})?$", "a percentage from 0 to 100")]
Rate = Annotated[Decimal, BeforeValidator(_rate), _schema(r"^[0-9]{1,14}(\.[0-9]{1,4})?$", "a rate, four places at most")]


class In(BaseModel):
    model_config = ConfigDict(extra="forbid")


class ClassAreaIn(In):
    """A class's default depreciation per area: the key and the life in periods."""
    area_code: Code
    key_code: Code
    life_periods: Optional[Life] = None


class AreaChangeIn(In):
    """How one asset is depreciated in one area from a date: a new key, life, total units or scrap."""
    area_code: Code
    key_code: Optional[Code] = None
    life_periods: Optional[Life] = None
    total_units: Optional[Units] = None
    scrap_value: Optional[PosMoney] = None


class AssetAreaIn(In):
    """Override a class default for one area on a new asset (the key and life copied from the class otherwise)."""
    area_code: Code
    key_code: Optional[Code] = None
    life_periods: Optional[Life] = None
    total_units: Optional[Units] = None
    scrap_value: Optional[PosMoney] = None


class ReceiverIn(In):
    """One receiver of a line item of an asset under construction: a share (percent) or an amount."""
    asset_id: Code
    percent: Optional[Pct] = None
    amount: Optional[PosMoney] = None


class LineRuleIn(In):
    """The settlement rule of one AuC line item: who receives it, in what shares."""
    line_item_transaction_id: Code
    receivers: Annotated[list[ReceiverIn], Field(min_length=1, max_length=50)]


class ClaimIn(In):
    asset_id: Code
    claim: Code
    amount: Optional[PosMoney] = None


class OpeningPoolIn(In):
    pool: Code
    balance: PosMoney


class CountResultIn(In):
    line_no: Optional[Seq] = None
    asset_id: Optional[Code] = None
    inventory_no: Optional[Code] = None
    result: Code
    note: Optional[Note] = None
    description: Optional[Name] = None


class OdooAssetIn(In):
    """One account.asset row as Odoo hands it over (the reader port fills these from its read-only connection)."""
    model_config = ConfigDict(extra="allow")
    id: int
    name: Optional[str] = None
    state: Optional[str] = None


def day(v: Optional[str]) -> Optional[date]:
    return date.fromisoformat(v) if v else None