feat(CORE-001A): add decimal unit value foundation
This commit is contained in:
19
app/domain/__init__.py
Normal file
19
app/domain/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from app.domain.units import (
|
||||
BaseCurrency,
|
||||
Money,
|
||||
PricePerWeight,
|
||||
Weight,
|
||||
WeightUnit,
|
||||
decimal_from_float,
|
||||
to_decimal,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"BaseCurrency",
|
||||
"WeightUnit",
|
||||
"Money",
|
||||
"Weight",
|
||||
"PricePerWeight",
|
||||
"to_decimal",
|
||||
"decimal_from_float",
|
||||
]
|
||||
245
app/domain/units.py
Normal file
245
app/domain/units.py
Normal 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
|
||||
Reference in New Issue
Block a user