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