feat(CORE-001A): add decimal unit value foundation

This commit is contained in:
Bu5hm4nn
2026-03-24 21:33:17 +01:00
parent 5ac66ea97b
commit a69fdf6762
5 changed files with 1153 additions and 9 deletions

245
app/domain/units.py Normal file
View File

@@ -0,0 +1,245 @@
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
from enum import StrEnum
class BaseCurrency(StrEnum):
USD = "USD"
EUR = "EUR"
CHF = "CHF"
class WeightUnit(StrEnum):
GRAM = "g"
KILOGRAM = "kg"
OUNCE_TROY = "ozt"
GRAMS_PER_KILOGRAM = Decimal("1000")
GRAMS_PER_TROY_OUNCE = Decimal("31.1034768")
DecimalLike = Decimal | int | str
def to_decimal(value: DecimalLike) -> Decimal:
if isinstance(value, bool):
raise TypeError("Boolean values are not valid Decimal inputs")
if isinstance(value, Decimal):
amount = value
elif isinstance(value, int):
amount = Decimal(value)
elif isinstance(value, str):
amount = Decimal(value)
else:
raise TypeError(f"Unsupported decimal input type: {type(value)!r}")
if not amount.is_finite():
raise ValueError("Decimal value must be finite")
return amount
def decimal_from_float(value: float) -> Decimal:
if not isinstance(value, float):
raise TypeError(f"Expected float, got {type(value)!r}")
amount = Decimal(str(value))
if not amount.is_finite():
raise ValueError("Decimal value must be finite")
return amount
def _coerce_currency(value: BaseCurrency | str) -> BaseCurrency:
if isinstance(value, BaseCurrency):
return value
try:
return BaseCurrency(value)
except ValueError as exc:
raise ValueError(f"Invalid currency: {value!r}") from exc
def _coerce_weight_unit(value: WeightUnit | str) -> WeightUnit:
if isinstance(value, WeightUnit):
return value
try:
return WeightUnit(value)
except ValueError as exc:
raise ValueError(f"Invalid weight unit: {value!r}") from exc
def weight_unit_factor(unit: WeightUnit) -> Decimal:
if unit is WeightUnit.GRAM:
return Decimal("1")
if unit is WeightUnit.KILOGRAM:
return GRAMS_PER_KILOGRAM
if unit is WeightUnit.OUNCE_TROY:
return GRAMS_PER_TROY_OUNCE
raise ValueError(f"Unsupported weight unit: {unit}")
def convert_weight(amount: Decimal, from_unit: WeightUnit, to_unit: WeightUnit) -> Decimal:
if from_unit is to_unit:
return amount
grams = amount * weight_unit_factor(from_unit)
return grams / weight_unit_factor(to_unit)
def convert_price_per_weight(amount: Decimal, from_unit: WeightUnit, to_unit: WeightUnit) -> Decimal:
if from_unit is to_unit:
return amount
return amount * weight_unit_factor(to_unit) / weight_unit_factor(from_unit)
@dataclass(frozen=True, slots=True)
class Money:
amount: Decimal
currency: BaseCurrency | str
def __post_init__(self) -> None:
object.__setattr__(self, "amount", to_decimal(self.amount))
object.__setattr__(self, "currency", _coerce_currency(self.currency))
@classmethod
def zero(cls, currency: BaseCurrency) -> Money:
return cls(amount=Decimal("0"), currency=currency)
def assert_currency(self, currency: BaseCurrency) -> Money:
if self.currency is not currency:
raise ValueError(f"Currency mismatch: {self.currency} != {currency}")
return self
def __add__(self, other: object) -> Money:
if not isinstance(other, Money):
return NotImplemented
if self.currency is not other.currency:
raise ValueError(f"Currency mismatch: {self.currency} != {other.currency}")
return Money(amount=self.amount + other.amount, currency=self.currency)
def __sub__(self, other: object) -> Money:
if not isinstance(other, Money):
return NotImplemented
if self.currency is not other.currency:
raise ValueError(f"Currency mismatch: {self.currency} != {other.currency}")
return Money(amount=self.amount - other.amount, currency=self.currency)
def __mul__(self, other: object) -> Money:
if isinstance(other, bool):
return NotImplemented
if isinstance(other, Decimal):
return Money(amount=self.amount * other, currency=self.currency)
if isinstance(other, int):
return Money(amount=self.amount * Decimal(other), currency=self.currency)
return NotImplemented
def __rmul__(self, other: object) -> Money:
return self.__mul__(other)
def __truediv__(self, other: object) -> Money:
if isinstance(other, bool):
return NotImplemented
if isinstance(other, Decimal):
return Money(amount=self.amount / other, currency=self.currency)
if isinstance(other, int):
return Money(amount=self.amount / Decimal(other), currency=self.currency)
return NotImplemented
def __neg__(self) -> Money:
return Money(amount=-self.amount, currency=self.currency)
@dataclass(frozen=True, slots=True)
class Weight:
amount: Decimal
unit: WeightUnit | str
def __post_init__(self) -> None:
object.__setattr__(self, "amount", to_decimal(self.amount))
object.__setattr__(self, "unit", _coerce_weight_unit(self.unit))
def to_unit(self, unit: WeightUnit) -> Weight:
return Weight(amount=convert_weight(self.amount, self.unit, unit), unit=unit)
def __add__(self, other: object) -> Weight:
if not isinstance(other, Weight):
return NotImplemented
other_converted = other.to_unit(self.unit)
return Weight(amount=self.amount + other_converted.amount, unit=self.unit)
def __sub__(self, other: object) -> Weight:
if not isinstance(other, Weight):
return NotImplemented
other_converted = other.to_unit(self.unit)
return Weight(amount=self.amount - other_converted.amount, unit=self.unit)
def __mul__(self, other: object) -> Weight | Money:
if isinstance(other, bool):
return NotImplemented
if isinstance(other, Decimal):
return Weight(amount=self.amount * other, unit=self.unit)
if isinstance(other, int):
return Weight(amount=self.amount * Decimal(other), unit=self.unit)
if isinstance(other, PricePerWeight):
adjusted_weight = self.to_unit(other.per_unit)
return Money(amount=adjusted_weight.amount * other.amount, currency=other.currency)
return NotImplemented
def __rmul__(self, other: object) -> Weight:
if isinstance(other, bool):
return NotImplemented
if isinstance(other, Decimal):
return Weight(amount=self.amount * other, unit=self.unit)
if isinstance(other, int):
return Weight(amount=self.amount * Decimal(other), unit=self.unit)
return NotImplemented
def __truediv__(self, other: object) -> Weight:
if isinstance(other, bool):
return NotImplemented
if isinstance(other, Decimal):
return Weight(amount=self.amount / other, unit=self.unit)
if isinstance(other, int):
return Weight(amount=self.amount / Decimal(other), unit=self.unit)
return NotImplemented
@dataclass(frozen=True, slots=True)
class PricePerWeight:
amount: Decimal
currency: BaseCurrency | str
per_unit: WeightUnit | str
def __post_init__(self) -> None:
amount = to_decimal(self.amount)
if amount < 0:
raise ValueError("PricePerWeight amount must be non-negative")
object.__setattr__(self, "amount", amount)
object.__setattr__(self, "currency", _coerce_currency(self.currency))
object.__setattr__(self, "per_unit", _coerce_weight_unit(self.per_unit))
def to_unit(self, unit: WeightUnit) -> PricePerWeight:
return PricePerWeight(
amount=convert_price_per_weight(self.amount, self.per_unit, unit),
currency=self.currency,
per_unit=unit,
)
def __mul__(self, other: object) -> Money | PricePerWeight:
if isinstance(other, bool):
return NotImplemented
if isinstance(other, Weight):
adjusted_weight = other.to_unit(self.per_unit)
return Money(amount=adjusted_weight.amount * self.amount, currency=self.currency)
if isinstance(other, Decimal):
return PricePerWeight(amount=self.amount * other, currency=self.currency, per_unit=self.per_unit)
if isinstance(other, int):
return PricePerWeight(amount=self.amount * Decimal(other), currency=self.currency, per_unit=self.per_unit)
return NotImplemented
def __rmul__(self, other: object) -> PricePerWeight:
if isinstance(other, bool):
return NotImplemented
if isinstance(other, Decimal):
return PricePerWeight(amount=self.amount * other, currency=self.currency, per_unit=self.per_unit)
if isinstance(other, int):
return PricePerWeight(amount=self.amount * Decimal(other), currency=self.currency, per_unit=self.per_unit)
return NotImplemented